Skip to content

Latest commit

 

History

History
162 lines (111 loc) · 9.62 KB

File metadata and controls

162 lines (111 loc) · 9.62 KB

Response to REVIEW_2026-07-17.md

Reviewer of the review: Grok (xAI), 2026-07-17. Scope: validate Fable’s review against the current tree; separate correctness from performance; do not edit REVIEW_2026-07-17.md.

Verdict

Fable’s review is mostly right. Every numbered bug was re-reproduced against the current build. Performance shape matches (on this machine, SwissProt ~26s for iter_xml vs ~6.5s for ET.iterparse).

Author mental model is also right: this was built to stream large files without eating RAM, not to win a speed race. A lot of the findings are either (a) real correctness holes or (b) the predictable cost of a naïve “Rust parses → Python sees every event” design.


Correctness vs performance

Real incorrect / incomplete results (not “just slow”)

Claim True? What actually happens
#2 Malformed XML → silent EOF Yes .ok()? turns any read_event error into None. Mid-file garbage stops the stream with no exception. Partial tree/count can look finished.
#3 Non-UTF-8 without BOM → text dropped Yes Latin-1 café yields start/end only; no text. Err(_) => continue on unescape + BOM-only decoding. Silent data loss.
#4 max_depth corrupts the tree Yes continue skips both deep starts and matching ends, so the stack never unwinds and later siblings vanish into the wrong parent. Confirmed: flat disappears.
#6 Attributes missing Yes, known Comment in Rust says attributes are ignored. xmltodict keeps @id / @ccy; this library does not. FIRDS amounts-with-currency would lose money-relevant fields.
CDATA dropped Yes Falls into _ => continue. Content gone, not an error.

These matter for “did I parse this file correctly?” — especially #2 and #3: partial success with no signal is the worst mode for large regulatory dumps.

Crashes / fails-closed (bad, but not silent wrong answers)

Claim True? Notes
#1 Rust get_edge_counts panics on <tag/> Yes emptypanic!("what").
#5 xml_to_dict RecursionError on deep docs Yes Depth 500 OK; 1000+ dies in _normalize_dict. iter_xml itself is fine at 5000+ (10k events). Streaming depth story holds; dict builder undermines it.
Python read_records on empty Extra Raises event = empty!?.
Python get_edge_counts on empty Extra Does not panic; silently undercounts self-closing tags (only counts start).

Empty tags are not one bug: iter_xml / xml_to_dict handle them; Rust edge counts explode; Python edge counts lie.

Docs / marketing overreach (true as critique)

  • “100% xmltodict” only holds for attribute-free cases the suite tests. AGENTS.md lists attributes ignored under Known Limitations and still claims 100% compatibility — inconsistent.
  • “Graceful fallbacks for malformed XML” is really silent truncate — a bad failure mode for this use case.
  • “734× with early termination” is real for early exit vs full xmltodict load, but any streaming parser (including stdlib) gets that. Not a unique win for this crate.
  • println! on every open, dead commented Rust, dual get_edge_counts, old pyo3/quick-xml, make clean nuking *.so broadly — all fair nits.

Performance: design tax, not a mystery

What was optimized for: constant-ish memory, user-controlled stop, path-based files, “don’t OOM on huge FIRDS/SwissProt.” That works: the iterator doesn’t build a full tree in Rust; depth is unbounded for streaming.

What was not optimized for: crossing the FFI boundary millions of times.

On a ~3 MB synthetic file (~550k events), approximate timings on this machine:

Approach Time
iter_xml (drain all) ~1.58 s
ET.iterparse + clear() ~0.27 s
Rust get_edge_counts (work in Rust, one dict back) ~0.66 s
xml_to_dict (Python rebuild over iter_xml) ~1.61 s

SwissProt: ~26 s vs ~6.5 s for ET — same ~3–4× shape as Fable’s numbers.

Takeaways:

  1. Rust is not the bottleneck when work stays in Rust (get_edge_counts is already much cheaper than draining the iterator).
  2. Per-event Python is the bottleneck — fresh "start".to_string(), new Vec buffer every next(), 3-tuple into Python under the GIL, every event.
  3. xml_to_dict does not buy much over the slow iterator — almost all time is already in iter_xml.
  4. Comparing only to xmltodict hides that stdlib streaming already does the memory job faster.

“Dumb choices” is fair for throughput, not random: simplest way to expose a SAX-like stream from PyO3. Classic “get it working / keep memory flat” scaffolding.


Where to soften Fable a bit

  1. Not everything “defeats its own purpose.”
    Streaming + early break + constant memory still holds for iter_xml. The design fails as a faster alternative to ET; it does not fail as “walk a 300 MB file and stop when I want” — unless silent error/encoding loss bites.

  2. Attributes are intentional incomplete, not a surprise bug.
    Honesty failure is documentation (“100%”), not a silent regression of a promised feature.

  3. “Production ready / graceful error handling” in AGENTS is aspirational copy, not evidence the code is solid under adversarial XML.

  4. max_depth is not real protection today — broken switch. Real protection for the threat model is “user breaks the loop” (and not calling recursive _normalize_dict on insane depth).

  5. Empty-tag panic has limited blast radius — only Rust get_edge_counts. Full iteration and xml_to_dict are fine with <a/>. Still a landmine if that API is used on real XML.


Bottom line

Layer Status
Memory / streaming idea Sound; matches why this exists.
Speed Never really optimized; per-event FFI + allocs explain “only ~1.1× vs xmltodict” and “slower than ET”.
Correctness landmines Silent stop, encoding/CDATA drop, max_depth corruption, attr omission — can produce wrong or incomplete answers, not just slow ones.
Crashes Empty in Rust edge counts; deep xml_to_dict recursion.
Fable’s priority order Right: fix silent failure modes first; empty panic is a one-liner; then decide identity (batch/filter/aggregate in Rust vs polish the event stream).

Roughly half the review is “boundary never optimized” (performance relative to original goal). Half is “several paths lie or panic under normal XML” (result integrity). The second half is what to fix even if beating ET.iterparse is never a goal.


Suggested fix priority (for implementer)

Aligned with Fable; reordered slightly for “wrong answers first”:

  1. Stop silent truncation (bug #2 / #3) — raise PyValueError (or yield a clear error event) on parse errors and undecodable text; do not fake EOF.
  2. Handle empty in Rust get_edge_counts — count without push, no panic; align Python get_edge_counts to count self-closing tags.
  3. Fix or remove max_depth — if kept, skip only descendants without stranding the stack; never skip matching end events for open elements you already pushed.
  4. Make _normalize_dict iterative — kill RecursionError on deep-but-valid trees; or normalize on end events.
  5. CDATA — yield as text (or document loss); do not silent-drop.
  6. Attributes — either emit (e.g. @attr / attribute events) or scope README/AGENTS claims honestly; add a failing test if claiming xmltodict parity.
  7. Docs — drop “production ready / 100% / graceful fallbacks” until true; reframe 734× as streaming early-exit vs full slurp, not unique magic.
  8. Hygiene — remove println!; gate verbose; scope make clean; delete or quarantine dead experiments; consider pyo3/quick-xml upgrades later.
  9. Perf (optional identity work) — only after correctness: intern event names / enum, reuse read buffer, batch N events per FFI call, or move aggregation/dict-build into Rust; re-benchmark vs ET.iterparse, not only xmltodict.

Tests to add (adversarial)

  • Self-closing tags through iter_xml, both get_edge_counts, xml_to_dict, read_records
  • Malformed mid-file must raise (or error event), not truncate
  • Declared ISO-8859-1 / other non-UTF-8 without BOM
  • CDATA content preserved (or explicit skip policy)
  • Attributes: either match xmltodict or assert documented absence
  • max_depth: truncated-but-consistent tree, no sibling absorption
  • Depth past sys.getrecursionlimit() for xml_to_dict
  • No stdout pollution on normal iter_xml open

Local reproduction notes (2026-07-17)

# empty / rust edge counts
get_edge_counts('<root><a/></root>')  → PanicException: what
py get_edge_counts same file          → {('root',): 1}  # undercounts <a/>
iter_xml                              → empty event OK
xml_to_dict                           → {'root': {'a': None}} OK

# malformed
list(iter_xml('...two</WRONG>...'))   → stops after text 'two', no error

# latin-1 café
events                                → start/end only, text gone

# max_depth=2 on <r><deep><x><y>v</y></x></deep><flat>f</flat></r>
                                      → {'r': {'deep': {'x': None}}}  # flat gone

# depth
xml_to_dict depth 1000+               → RecursionError
iter_xml depth 5000                   → fine

# attrs
xml_to_dict vs xmltodict              → drops @id/@ccy

# CDATA
                                      → text discarded

SwissProt full drain: iter_xml ~26.3s / 7.97M events; ET.iterparse start+end+clear ~6.5s / 5.95M events.