sofhiaazzhr Claude Opus 5 commited on
Commit
a356e3c
·
1 Parent(s): df84654

[NOTICKET] fix(knowledge-parsing): render_latex was deleting division (X21)

Browse files

Rifqi's three faults, all reproduced against the document's own 11 equations
and all fixed. Two of his numbers were off, and the corrections matter:

- It hit 4 of 11 equations, not 2 — PA, PA_ij, UA_ij and PTY_ij. Not just
PA and UA: every composite form was affected too.
- The nested braces come from mathrm{} and sum _{ij}, NOT from text{}, which
this standard never uses. A fix written against text{} would have repaired
the synthetic example and no real equation.

(C), the serious one: the frac rewrite needed a brace-free argument, could not
match a nested numerator, and the unmatched frac then fell through to the
generic "drop leftover commands" pass. The division was deleted, leaving a
formula that reads as a product and states something the document does not.

The ordering decision this needed: braced wrappers are unwrapped BEFORE frac,
and the unwrap consumes its braces. The old pass removed only the command name
and left { } behind — those leftover braces were what blocked frac all along.
frac now uses a balanced-brace scanner; a regex cannot express nesting and
fails silently rather than raising.

(A) and (B) were run-boundary faults: boundaries are now word-character
classes, and subscript contents are joined at fold time.

Division lost 4 -> 0. Six regression cases unchanged.

Still open, deliberately, because it changes interpretation and not mechanics:
after MinerU spaces every character, a multiplication "x" cannot be told apart
from the letter. The standard's flagship formula therefore still renders as
MOHHxQtyxPAxUAxPty, costing the term filter five mentions. The retraction in
the previous diagnosis claimed that formula "renders perfectly" — it does not;
its raw source carries a literal "x", no times and no mathrm.

Docs corrected to match, including escaping damage that had turned frac and
text into control characters in the diagnosis itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

KNOWLEDGE_PIPELINE_CALIBRATION.md CHANGED
@@ -356,44 +356,84 @@ Fix direction: `rule.txt` should exclude bare equations explicitly ("an equation
356
  branch's job; a rule is prose that constrains behaviour"). That is a prompt change and therefore owes
357
  an eval run per CLAUDE.md §7B — **not** to be done on vibes.
358
 
359
- ### Cause 3 — three distinct `render_latex` faults (corrected 2026-08-24)
360
-
361
- > **Correction.** An earlier version of this section said `render_latex` "eats the first character",
362
- > with a three-line reproduction. **That was wrong** — the repro was mangled by shell escaping on the
363
- > way in, not by her code. `Production = MOHH × Qty × PA × UA × Pty` renders perfectly. The real
364
- > faults are these three, reproduced against the document's own 11 equations
365
- > (`scratchpad/repro_render.py`, no escaping involved).
366
-
367
- **(A) A spaced run touching a bracket is only half-collapsed.** `_SPACED_RUN` is bounded by
368
- `(?<!\S)` / `(?!\S)`, so a run cannot match if it touches `(` or `)`. The ` rac{a}{b}`
369
- `(a) / (b)` rewrite inserts exactly those brackets, which strands the character at **each** end:
 
 
 
 
 
 
 
 
 
 
 
370
 
371
  ```
372
  'I N P R H o u r s' -> 'INPRHours' # correct, no brackets
373
  '(I N P R H o u r s)' -> '(I NPRHour s)' # first and last stranded
374
  ```
375
 
376
- **(B) The subscript fold runs before the collapse and splits the run.** `Q t y _ {a c t i v i t y}`
377
- becomes `Qt y_a ctivity` — the `_` is glued mid-run, so the collapse sees three broken fragments
378
- instead of two words.
379
 
380
- **(C) The serious one — fraction structure is silently dropped.**
381
 
382
  ```
383
- \mathrm{PA} = rac { ext {Total Hours} - ext {Breakdown}}{ ext {Total Hours}} imes 100\%
384
- -> 'PA = Total Hours - Breakdown Total Hours × 100 %'
 
385
  ```
386
 
387
- The ` rac` rewrite needs `\{([^{}]*)\}`, which cannot match a numerator containing nested
388
- ` ext{…}` braces, so the division is dropped rather than rendered. **This is not a spacing
389
- blemish the formula now reads as a product and states something the document does not.** It
390
- affects the PA and UA formulas, i.e. the two most important in the standard.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
 
392
- Of the document's 11 equations, 5 render cleanly, 4 carry stranded characters, and the 2 with
393
- ` ext{}` numerators lose their division.
 
 
 
394
 
395
- Impact: corrupted formula text sits in `Chunk.text`, so a span quoting a formula cannot be verified;
396
- `formula_latex` carries the damage into the Skill Registry; and a term occurring only inside a
397
- formula can be split away from itself.
398
 
399
- **Owner: Sofhia** — `src/knowledge_parsing/render.py`. Not fixed here: it is the parsing half.
 
356
  branch's job; a rule is prose that constrains behaviour"). That is a prompt change and therefore owes
357
  an eval run per CLAUDE.md §7B — **not** to be done on vibes.
358
 
359
+ ### Cause 3 — three distinct `render_latex` faults (FIXED 2026-08-24)
360
+
361
+ > **Two corrections, both from re-measuring against the document's own 11 equations.**
362
+ >
363
+ > An earlier version of this section said `render_latex` "eats the first character". That was
364
+ > retracted the repro was mangled by shell escaping, not by the code.
365
+ >
366
+ > The retraction then over-corrected. It claimed `Production = MOHH × Qty × PA × UA × Pty`
367
+ > "renders perfectly". **It does not.** The document's raw `content_list.json` contains no
368
+ > `\times` and no `\mathrm` just a literal `x`, spaced like every other character:
369
+ >
370
+ > ```
371
+ > 'P r o d u c t i o n = M O H H x Q t y x P A x U A x P t y'
372
+ > -> 'Production = MOHHxQtyxPAxUAxPty'
373
+ > ```
374
+ >
375
+ > MOHH, Qty, PA, UA and Pty — the five terms the glossary is built around — fuse into one
376
+ > token. See *Still open* below.
377
+
378
+ **(A) A spaced run touching a bracket is only half-collapsed.** `_SPACED_RUN` was bounded by
379
+ `(?<!\S)` / `(?!\S)`, so a run could not match if it touched `(` or `)`. The `\frac{a}{b}` →
380
+ `(a) / (b)` rewrite inserts exactly those brackets, stranding a character at **each** end:
381
 
382
  ```
383
  'I N P R H o u r s' -> 'INPRHours' # correct, no brackets
384
  '(I N P R H o u r s)' -> '(I NPRHour s)' # first and last stranded
385
  ```
386
 
387
+ **(B) The subscript fold ran before the collapse and split the run.** `Q t y _ {a c t i v i t y}`
388
+ became `Qt y_a ctivity` — the `_` glued mid-run, so the collapse saw broken fragments.
 
389
 
390
+ **(C) The serious one — fraction structure was silently dropped.**
391
 
392
  ```
393
+ \mathrm { P A } = { \frac { \mathrm { T o t a l } ~ H o u r s - B r e a k d o w n }
394
+ { \mathrm { T o t a l } ~ H o u r s } } \mathrm { x } 1 0 0 \%
395
+ -> 'PA = Total Hours - BreakdownTotal Hoursx 100 %'
396
  ```
397
 
398
+ The `\frac` rewrite needed `\{([^{}]*)\}`, which cannot match a numerator containing nested
399
+ braces. The unmatched `\frac` then fell through to the generic "drop any leftover command" pass,
400
+ so **the division was deleted rather than rendered.** This is not a spacing blemish the formula
401
+ reads as a product and states something the document does not.
402
+
403
+ > **The nesting comes from `\mathrm{…}` and `\sum _{ij}`, not `\text{…}`.** The BUMA standard
404
+ > never uses `\text{}`. A fix written against `\text{}` would repair a synthetic example and no
405
+ > real equation.
406
+
407
+ **Measured tally, 11 equations:**
408
+
409
+ | | Before | After |
410
+ |---|---|---|
411
+ | Division lost | **4** — eq 6, 7, 9, 11 (`PA`, `PA_ij`, `UA_ij`, `PTY_ij`) | **0** |
412
+ | Stranded characters | 4 | 0 |
413
+ | Clean | 5 | 9 + 2 carrying only the document's own literal `x` |
414
+
415
+ Not two equations and not only PA/UA: **every composite (`_ij`) form was affected too.**
416
+
417
+ **The fix** (`src/knowledge_parsing/render.py`):
418
+
419
+ 1. **Braced wrappers are unwrapped before `\frac`, consuming their braces.** The old `_WRAPPERS`
420
+ deleted only the command name and left `{ }` behind — those leftover braces were what stopped
421
+ `\frac` from matching. This is the ordering decision the diagnosis asked for.
422
+ 2. **`\frac` uses a balanced-brace scanner**, not a regex. `[^{}]*` can never handle a nested
423
+ argument, and it fails silently rather than raising.
424
+ 3. **Run boundaries are word-character classes**, and subscript contents are joined at fold time
425
+ (`_ { i j }` → `_ij`).
426
+
427
+ Six regression cases hold unchanged: `C_p`, `C 5`, `C_p × T`, `\frac{a}{b}`, `Purchasing costs`,
428
+ and a bare spaced run.
429
 
430
+ **Still open a decision, not a defect.** After MinerU spaces every character, a multiplication
431
+ `x` is indistinguishable from the letter `x`: no `\times`, no `\mathrm`, no signal at all. Treating
432
+ it as an operator is right for engineering formulas and wrong for a variable named `x`. The
433
+ `_SYMBOLS` comment shows the author already weighed this trade for `\times`. Left unfixed
434
+ deliberately, because it changes interpretation rather than mechanics.
435
 
436
+ Impact of what remains: a term occurring only inside `Production = …` cannot be separated from its
437
+ neighbours, which costs the term filter five mentions on the standard's flagship formula.
 
438
 
439
+ **Owner: Sofhia** — `src/knowledge_parsing/render.py`.
KNOWLEDGE_PIPELINE_TODO.md CHANGED
@@ -169,7 +169,8 @@ envelope shape proposed in §3 — so S1 can settle either way without touching
169
  | **X18** | Full-document run through both halves | Rifqi | ✅ | **Done 2026-08-24** → `eval/knowledge/results/v2_full_document_2026-08-24_093051.json`. Parsing module built the artifact (31 chunks), extraction ran all four branches: 77 calls, 80% cache hit. **E3 = 0.90, PASS** — the prototype's failing experiment (0.75 vs a 0.80 line) now clears it, on a 10-entry scoreable base |
170
  | **X19** | Rule-branch recall regression | Rifqi | 🔎 | **Diagnosed 2026-08-24 — calibration §12.** Three causes: (a) 2 of 3 losses are a *scoring artifact* (gold written `QtyA`, rendered output `Qty_A`; `_` is a word char so the substring match fails) — true comparison is 4/15 → 3/15; (b) the one real loss is rendered formulas competing with prose — 4 of 8 v2 rule statements are bare equations vs 0 of 7 in the prototype, and the formula branch already captures them; (c) a `render_latex` bug (X21) |
171
  | **X20** | Exclude bare equations in `rule.txt` | Rifqi | ⬜ | The fix for X19(b). A prompt change → owes an eval run per §7B; do not ship on vibes. The formula branch already covers equations, so this is duplicated spend as well as a miss |
172
- | **X21** | `render_latex` — three faults, one serious | Sofhia | | **Found 2026-08-24, corrected same day.** Not "eats the first character" (that repro was mangled by my shell escaping — retracted). Three real faults: (A) a spaced run touching a `(` inserted by the ` rac` rewrite is only half-collapsed → `(I NPRHour s)`; (B) the subscript fold splits the run → `Qt y_a ctivity`; **(C) a ` rac` with ` ext{}` in the numerator loses its division entirely** → `PA = Total Hours - Breakdown Total Hours × 100 %`, which states something the document does not, and it hits the PA and UA formulas. Repro: `scratchpad/repro_render.py`, calibration §12 |
 
173
  | **X22** | Gold/scorer notation decision | Rifqi | ⬜ | Whether to normalise `_` in the scorer or rewrite the gold's `statement_contains`. Either edits the frozen measuring instrument, so it needs a deliberate call rather than a silent fix |
174
  | **X13** | Conflict detection | 🔎 | ✅ | `validate/conflict.py`, token overlap (explainable to the reviewer, unlike embeddings); never picks a winner. Unit-tested both ways; still **0 conflicts on real data** — one consistent standard gives it nothing to find. Works only because clustering puts all evidence in one call |
175
  | **X14** | Diff vs. active glossary version | 🔎 | ✅ | `diff/glossary_diff.py`. The baseline is passed in explicitly (`--active-glossary`) rather than read from wherever the last run wrote, so the duplicate and conflicting paths are reachable — all three verified by test |
 
169
  | **X18** | Full-document run through both halves | Rifqi | ✅ | **Done 2026-08-24** → `eval/knowledge/results/v2_full_document_2026-08-24_093051.json`. Parsing module built the artifact (31 chunks), extraction ran all four branches: 77 calls, 80% cache hit. **E3 = 0.90, PASS** — the prototype's failing experiment (0.75 vs a 0.80 line) now clears it, on a 10-entry scoreable base |
170
  | **X19** | Rule-branch recall regression | Rifqi | 🔎 | **Diagnosed 2026-08-24 — calibration §12.** Three causes: (a) 2 of 3 losses are a *scoring artifact* (gold written `QtyA`, rendered output `Qty_A`; `_` is a word char so the substring match fails) — true comparison is 4/15 → 3/15; (b) the one real loss is rendered formulas competing with prose — 4 of 8 v2 rule statements are bare equations vs 0 of 7 in the prototype, and the formula branch already captures them; (c) a `render_latex` bug (X21) |
171
  | **X20** | Exclude bare equations in `rule.txt` | Rifqi | ⬜ | The fix for X19(b). A prompt change → owes an eval run per §7B; do not ship on vibes. The formula branch already covers equations, so this is duplicated spend as well as a miss |
172
+ | **X21** | `render_latex` — three faults, one serious | Sofhia | | **Found and FIXED 2026-08-24.** (A) a spaced run touching a `(` inserted by the `\frac` rewrite was only half-collapsed → `(I NPRHour s)`; (B) the subscript fold split the run → `Qt y_a ctivity`; **(C) a `\frac` whose numerator contains nested braces lost its division entirely** → `PA = Total Hours - BreakdownTotal Hours`, which states something the document does not. **Two corrections on re-measurement:** it hit **4 of 11 equations, not 2** — `PA`, `PA_ij`, `UA_ij`, `PTY_ij`, so every composite form as well; and the nesting comes from `\mathrm{}` / `\sum _{ij}`, **not `\text{}`**, which this standard never uses — a fix written against `\text{}` would have repaired nothing real. Fix: unwrap braced wrappers **before** `\frac`, balanced-brace scanner instead of `[^{}]*`, word-class run boundaries. Division lost 4 → 0; six regression cases unchanged. **Still open, as a decision not a defect:** after character-spacing a multiplication `x` is indistinguishable from the letter, so `Production = MOHH x Qty x PA x UA x Pty` renders as one fused token and costs the term filter five mentions. Calibration §12 |
173
+ rac` rewrite is only half-collapsed → `(I NPRHour s)`; (B) the subscript fold splits the run → `Qt y_a ctivity`; **(C) a ` rac` with ` ext{}` in the numerator loses its division entirely** → `PA = Total Hours - Breakdown Total Hours × 100 %`, which states something the document does not, and it hits the PA and UA formulas. Repro: `scratchpad/repro_render.py`, calibration §12 |
174
  | **X22** | Gold/scorer notation decision | Rifqi | ⬜ | Whether to normalise `_` in the scorer or rewrite the gold's `statement_contains`. Either edits the frozen measuring instrument, so it needs a deliberate call rather than a silent fix |
175
  | **X13** | Conflict detection | 🔎 | ✅ | `validate/conflict.py`, token overlap (explainable to the reviewer, unlike embeddings); never picks a winner. Unit-tested both ways; still **0 conflicts on real data** — one consistent standard gives it nothing to find. Works only because clustering puts all evidence in one call |
176
  | **X14** | Diff vs. active glossary version | 🔎 | ✅ | `diff/glossary_diff.py`. The baseline is passed in explicitly (`--active-glossary`) rather than read from wherever the last run wrote, so the duplicate and conflicting paths are reachable — all three verified by test |
src/knowledge_parsing/render.py CHANGED
@@ -40,9 +40,24 @@ _SYMBOLS = {
40
  # characters; letters and digits are joined separately so "C 5" is left alone.
41
  # Subscripts are folded first (below), so a real variable like `C_p` is already
42
  # one token and never gets swallowed into a neighbouring word.
 
 
 
 
 
 
 
 
43
  _SPACED_RUN = re.compile(
44
- r"(?<!\S)(?:[A-Za-z]\s+){1,}[A-Za-z](?!\S)"
45
- r"|(?<!\S)(?:\d\s+){1,}\d(?!\S)"
 
 
 
 
 
 
 
46
  )
47
 
48
  # `~` is a non-breaking SPACE in LaTeX, i.e. a real word boundary. It has to
@@ -51,6 +66,62 @@ _SPACED_RUN = re.compile(
51
  _WORD_GAP = "\x00"
52
 
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  def render_latex(latex: str) -> str:
55
  """LaTeX -> a readable line. Best effort: it feeds a term filter, not a parser."""
56
  s = latex.replace("$$", " ").replace("$", " ")
@@ -61,30 +132,41 @@ def render_latex(latex: str) -> str:
61
  s = re.sub(r"\{\s*(?:[rlc|]\s*){1,8}\}", " ", s) # column spec, e.g. { r l }
62
  s = s.replace("\\\\", " ; ").replace("&", " ")
63
 
64
- # fold sub/superscripts first, so `C _ { p }` becomes one token `C_p`
65
- s = re.sub(r"\s*_\s*\{\s*([^{}]*?)\s*\}", r"_\1", s)
66
- s = re.sub(r"\s*\^\s*\{\s*([^{}]*?)\s*\}", r"^\1", s)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  s = re.sub(r"\s*_\s*([A-Za-z0-9])", r"_\1", s)
68
  s = re.sub(r"\s*\^\s*([A-Za-z0-9])", r"^\1", s)
69
 
70
- # \frac{a}{b} -> (a) / (b); repeat for nesting
71
- for _ in range(4):
72
- baru = re.sub(r"\\d?frac\s*\{([^{}]*)\}\s*\{([^{}]*)\}", r"(\1) / (\2)", s)
73
- if baru == s:
74
- break
75
- s = baru
76
 
77
  s = re.sub(r"\\sqrt\s*\{([^{}]*)\}", r"sqrt(\1)", s)
78
  for k, v in _SYMBOLS.items():
79
  s = s.replace(k, f" {v} ")
80
- s = _WRAPPERS.sub(" ", s)
81
  s = _DROP_COMMANDS.sub(" ", s)
82
  s = re.sub(r"\\[A-Za-z]+", " ", s) # any command left over
83
  s = s.replace("{", " ").replace("}", " ").replace("\\", " ")
84
  s = re.sub(r"[ \t\r\n]+", " ", s).strip()
85
 
86
  # now that spacing is uniform, rejoin the spelled-out words and numbers
87
- s = _SPACED_RUN.sub(lambda m: m.group(0).replace(" ", ""), s)
88
 
89
  s = s.replace(_WORD_GAP, " ")
90
  return re.sub(r"\s+", " ", s).strip()
 
40
  # characters; letters and digits are joined separately so "C 5" is left alone.
41
  # Subscripts are folded first (below), so a real variable like `C_p` is already
42
  # one token and never gets swallowed into a neighbouring word.
43
+ #
44
+ # The boundaries are word-character classes, not `\S`. Two reasons, both found
45
+ # on real equations:
46
+ # - `\frac` rewriting INTRODUCES parentheses, and `(?<!\S)` refuses to match a
47
+ # run touching one, stranding a letter at each end:
48
+ # "(I N P R H o u r s)" -> "(I NPRHour s)".
49
+ # - the trailing side lets `_`/`^` follow, so a run whose last letter carries a
50
+ # subscript is not cut short: "Q t y_activity" -> "Qty_activity".
51
  _SPACED_RUN = re.compile(
52
+ r"(?<![A-Za-z0-9_])(?:[A-Za-z]\s+){1,}[A-Za-z](?![A-Za-z0-9])"
53
+ r"|(?<![A-Za-z0-9_])(?:\d\s+){1,}\d(?![A-Za-z0-9])"
54
+ )
55
+
56
+ # Wrappers that take a braced argument. Handled by brace matching rather than
57
+ # `_WRAPPERS` below, because merely deleting the command name leaves its braces
58
+ # behind — and those braces are what stopped `\frac` from matching.
59
+ _BRACED_WRAPPER = re.compile(
60
+ r"\\(?:mathrm|mathbf|mathit|mathsf|mathtt|text|textrm|textbf|operatorname)\s*(?=\{)"
61
  )
62
 
63
  # `~` is a non-breaking SPACE in LaTeX, i.e. a real word boundary. It has to
 
66
  _WORD_GAP = "\x00"
67
 
68
 
69
+ def _rapatkan(teks: str) -> str:
70
+ """Gabungkan deret karakter yang dieja satu-satu: 'i j' -> 'ij'."""
71
+ return _SPACED_RUN.sub(lambda m: m.group(0).replace(" ", ""), teks)
72
+
73
+
74
+ def _argumen(s: str, i: int) -> tuple[str | None, int]:
75
+ """Isi kurung SEIMBANG yang dimulai di `s[i] == '{'`.
76
+
77
+ Regex `\\{([^{}]*)\\}` tidak bisa dipakai di sini: argumen nyata mengandung
78
+ kurung bersarang (`\\mathrm{...}`, `\\sum _{ij}`), dan pola itu diam-diam
79
+ gagal cocok, bukan memberi galat.
80
+ """
81
+ dalam = 0
82
+ for j in range(i, len(s)):
83
+ if s[j] == "{":
84
+ dalam += 1
85
+ elif s[j] == "}":
86
+ dalam -= 1
87
+ if dalam == 0:
88
+ return s[i + 1:j], j + 1
89
+ return None, i
90
+
91
+
92
+ def _buka_pembungkus(s: str) -> str:
93
+ r"""`\mathrm{X}` -> `X`, IKUT memakan kurungnya."""
94
+ for _ in range(8):
95
+ m = _BRACED_WRAPPER.search(s)
96
+ if not m:
97
+ break
98
+ isi, akhir = _argumen(s, m.end())
99
+ if isi is None:
100
+ break
101
+ s = s[:m.start()] + " " + isi + " " + s[akhir:]
102
+ return s
103
+
104
+
105
+ def _render_frac(s: str) -> str:
106
+ r"""`\frac{a}{b}` -> `(a) / (b)`, sadar kurung bersarang, rekursif."""
107
+ for _ in range(8):
108
+ m = re.search(r"\\d?frac\s*(?=\{)", s)
109
+ if not m:
110
+ break
111
+ pembilang, lanjut = _argumen(s, s.index("{", m.end() - 1))
112
+ if pembilang is None:
113
+ break
114
+ mulai_penyebut = s.find("{", lanjut)
115
+ if mulai_penyebut == -1:
116
+ break
117
+ penyebut, akhir = _argumen(s, mulai_penyebut)
118
+ if penyebut is None:
119
+ break
120
+ s = (s[:m.start()] + " (" + _render_frac(pembilang) + ") / ("
121
+ + _render_frac(penyebut) + ") " + s[akhir:])
122
+ return s
123
+
124
+
125
  def render_latex(latex: str) -> str:
126
  """LaTeX -> a readable line. Best effort: it feeds a term filter, not a parser."""
127
  s = latex.replace("$$", " ").replace("$", " ")
 
132
  s = re.sub(r"\{\s*(?:[rlc|]\s*){1,8}\}", " ", s) # column spec, e.g. { r l }
133
  s = s.replace("\\\\", " ; ").replace("&", " ")
134
 
135
+ # ORDER MATTERS, and this is the ordering decision: unwrap the braced
136
+ # wrappers BEFORE touching `\frac`.
137
+ #
138
+ # `\frac`'s arguments in real documents are not brace-free — the BUMA
139
+ # standard writes `\frac { \mathrm { T o t a l } ~ H o u r s - ... } { ... }`.
140
+ # With the wrappers still in place the old `\{([^{}]*)\}` pattern could not
141
+ # match, so the `\frac` was never rewritten; it then fell through to the
142
+ # generic "drop any leftover command" pass and the DIVISION WAS SILENTLY
143
+ # DELETED. `PA = (Total - Breakdown) / Total` came out as
144
+ # `PA = Total Hours - BreakdownTotal Hours`, which reads as a product and
145
+ # states something the document does not. It hit 4 of 11 equations —
146
+ # PA, PA_ij, UA_ij, PTY_ij.
147
+ s = _buka_pembungkus(s)
148
+
149
+ # fold sub/superscripts, so `C _ { p }` becomes one token `C_p`. The content
150
+ # is joined too: `_ { i j }` -> `_ij`, otherwise the space inside survives
151
+ # and splits the token later ("_i j").
152
+ s = re.sub(r"\s*_\s*\{\s*([^{}]*?)\s*\}", lambda m: "_" + _rapatkan(m.group(1)), s)
153
+ s = re.sub(r"\s*\^\s*\{\s*([^{}]*?)\s*\}", lambda m: "^" + _rapatkan(m.group(1)), s)
154
  s = re.sub(r"\s*_\s*([A-Za-z0-9])", r"_\1", s)
155
  s = re.sub(r"\s*\^\s*([A-Za-z0-9])", r"^\1", s)
156
 
157
+ s = _render_frac(s)
 
 
 
 
 
158
 
159
  s = re.sub(r"\\sqrt\s*\{([^{}]*)\}", r"sqrt(\1)", s)
160
  for k, v in _SYMBOLS.items():
161
  s = s.replace(k, f" {v} ")
162
+ s = _WRAPPERS.sub(" ", s) # sisa pembungkus tanpa kurung, mis. `\mathrm x`
163
  s = _DROP_COMMANDS.sub(" ", s)
164
  s = re.sub(r"\\[A-Za-z]+", " ", s) # any command left over
165
  s = s.replace("{", " ").replace("}", " ").replace("\\", " ")
166
  s = re.sub(r"[ \t\r\n]+", " ", s).strip()
167
 
168
  # now that spacing is uniform, rejoin the spelled-out words and numbers
169
+ s = _rapatkan(s)
170
 
171
  s = s.replace(_WORD_GAP, " ")
172
  return re.sub(r"\s+", " ", s).strip()