leptris 1.9.197.2-arm-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3890 -0
  3. data/CLAUDE.md +115 -0
  4. data/CONTEXT.md +39 -0
  5. data/LICENSE.md +33 -0
  6. data/README.adoc +755 -0
  7. data/Rakefile +407 -0
  8. data/TODO.perf/01-ext-accelerated-default-reads.md +28 -0
  9. data/TODO.perf/02-auto-enable-native-layer.md +19 -0
  10. data/TODO.perf/03-bulk-xpath-result-materialization.md +18 -0
  11. data/TODO.perf/04-ext-bound-serialization.md +15 -0
  12. data/TODO.perf/05-native-surface-completion.md +19 -0
  13. data/TODO.perf/06-perf-battery-native-legs.md +16 -0
  14. data/TODO.perf/07-native-read-floor.md +31 -0
  15. data/TODO.perf/08-version-coherent-native-mutations.md +24 -0
  16. data/TODO.perf/09-adoption-lift-fast-path.md +38 -0
  17. data/TODO.perf/10-bulk-attribute-materialization.md +20 -0
  18. data/TODO.perf/11-moxml-gap-battery.md +18 -0
  19. data/TODO.perf/12-document-lifetime-in-c.md +47 -0
  20. data/TODO.perf/13-structural-memos.md +49 -0
  21. data/TODO.perf/14-insert-family-c-bound.md +31 -0
  22. data/TODO.perf/15-compiled-expression-cache.md +35 -0
  23. data/TODO.perf/16-at-xpath-single-result-seam.md +38 -0
  24. data/TODO.perf/17-precomputed-fast-path-flags.md +35 -0
  25. data/TODO.perf/18-inner-html-one-c-pass.md +32 -0
  26. data/TODO.perf/19-lazy-node-pointer.md +39 -0
  27. data/TODO.perf/20-css-translation-cache.md +24 -0
  28. data/TODO.perf/21-key-memo-consult.md +20 -0
  29. data/TODO.perf/22-eager-nodeset-materialization.md +30 -0
  30. data/TODO.perf/23-cbound-value-mutations.md +27 -0
  31. data/TODO.perf/24-immutable-read-lanes.md +22 -0
  32. data/TODO.perf/25-scope-owned-bulk-path.md +39 -0
  33. data/TODO.perf/26-post-mutation-memo-seeding.md +26 -0
  34. data/TODO.perf/27-c-yield-traversal.md +38 -0
  35. data/TODO.perf/28-address-fills.md +25 -0
  36. data/TODO.perf/29-ns-xpath-compiled.md +23 -0
  37. data/TODO.perf/30-copy-and-element-child-faces.md +31 -0
  38. data/TODO.perf/31-battery-rounds-4-8.md +23 -0
  39. data/TODO.perf/32-clean-host-floor-table.md +29 -0
  40. data/TODO.perf/33-cbound-root-set.md +29 -0
  41. data/TODO.perf/34-fragment-fast-lane.md +41 -0
  42. data/TODO.perf/35-parse-default-c-face.md +24 -0
  43. data/TODO.perf/36-raise-in-c-serializer-encoding.md +34 -0
  44. data/TODO.perf/37-document-lazy-pointer.md +28 -0
  45. data/TODO.perf/38-bulk-walk-and-bench-gate.md +23 -0
  46. data/TODO.restructure/01-constraint-compliance-audit.md +40 -0
  47. data/TODO.restructure/02-deep-copy-seam.md +21 -0
  48. data/TODO.restructure/03-evaluation-context-seam.md +18 -0
  49. data/TODO.restructure/04-spec-mece-restructure.md +20 -0
  50. data/TODO.restructure/05-memory-documentation.md +13 -0
  51. data/TODO.restructure/06-architecture-map-refresh.md +12 -0
  52. data/TODO.restructure/07-external-gates.md +16 -0
  53. data/TODO.restructure/08-new-code-audit-and-ext-memo.md +11 -0
  54. data/TODO.restructure/09-iteration-scope.md +29 -0
  55. data/TODO.restructure/10-cold-attr-read-diet.md +19 -0
  56. data/TODO.restructure/11-construction-factory-diet.md +16 -0
  57. data/TODO.restructure/12-resultattr-merge.md +12 -0
  58. data/TODO.restructure/13-spec-mece-followup.md +9 -0
  59. data/TODO.restructure/14-constraint-audit-refresh.md +15 -0
  60. data/TODO.restructure/15-readme-feature-documentation.md +10 -0
  61. data/TODO.restructure/16-claudemd-map-refresh.md +5 -0
  62. data/TODO.restructure/17-perf-drift-record.md +12 -0
  63. data/TODO.restructure/18-audit-spec-doubles-and-gem-contents.md +12 -0
  64. data/TODO.restructure/19-gitignore-hygiene.md +10 -0
  65. data/TODO.restructure/20-utf8proc-enablement.md +34 -0
  66. data/TODO.restructure/21-typeddata-variant-prototype.md +42 -0
  67. data/TODO.restructure/22-sax-drain-bulk-strip.md +28 -0
  68. data/docs/adr/0001-lockstep-mirror.md +17 -0
  69. data/docs/adr/0002-utf8-at-the-seam.md +19 -0
  70. data/docs/adr/0003-readonly-memoization-pattern.md +34 -0
  71. data/docs/adr/0004-lifetime-guard.md +21 -0
  72. data/docs/adr/0005-autoload-manifest-ordering.md +19 -0
  73. data/docs/adr/0006-ruby-variant-policy.md +25 -0
  74. data/ext/build_windows_native.rb +42 -0
  75. data/ext/leptris/native/extconf.rb +55 -0
  76. data/ext/leptris/native/native.c +2646 -0
  77. data/leptris.gemspec +43 -0
  78. data/lib/leptris/version.rb +5 -0
  79. data/lib/leptris/xml/attr.rb +76 -0
  80. data/lib/leptris/xml/c_string_array.rb +37 -0
  81. data/lib/leptris/xml/cdata.rb +27 -0
  82. data/lib/leptris/xml/comment.rb +27 -0
  83. data/lib/leptris/xml/css_to_xpath.rb +192 -0
  84. data/lib/leptris/xml/descriptor.rb +214 -0
  85. data/lib/leptris/xml/diff.rb +74 -0
  86. data/lib/leptris/xml/doc_type.rb +54 -0
  87. data/lib/leptris/xml/document.rb +716 -0
  88. data/lib/leptris/xml/document_fragment.rb +63 -0
  89. data/lib/leptris/xml/element.rb +771 -0
  90. data/lib/leptris/xml/entity_reference.rb +13 -0
  91. data/lib/leptris/xml/evaluation_context.rb +32 -0
  92. data/lib/leptris/xml/ffi.rb +1527 -0
  93. data/lib/leptris/xml/iteration_scope.rb +56 -0
  94. data/lib/leptris/xml/iterparse.rb +144 -0
  95. data/lib/leptris/xml/namespace.rb +43 -0
  96. data/lib/leptris/xml/native.so +0 -0
  97. data/lib/leptris/xml/native_layer.rb +92 -0
  98. data/lib/leptris/xml/node.rb +686 -0
  99. data/lib/leptris/xml/node_set.rb +212 -0
  100. data/lib/leptris/xml/parse_options.rb +97 -0
  101. data/lib/leptris/xml/plan_value.rb +121 -0
  102. data/lib/leptris/xml/processing_instruction.rb +90 -0
  103. data/lib/leptris/xml/pull.rb +212 -0
  104. data/lib/leptris/xml/relaxng.rb +126 -0
  105. data/lib/leptris/xml/result_attr.rb +39 -0
  106. data/lib/leptris/xml/result_text.rb +36 -0
  107. data/lib/leptris/xml/sax/document.rb +45 -0
  108. data/lib/leptris/xml/sax/dom_dispatch.rb +144 -0
  109. data/lib/leptris/xml/sax/parser.rb +326 -0
  110. data/lib/leptris/xml/sax/recorder.rb +374 -0
  111. data/lib/leptris/xml/sax.rb +14 -0
  112. data/lib/leptris/xml/schematron.rb +88 -0
  113. data/lib/leptris/xml/searchable.rb +361 -0
  114. data/lib/leptris/xml/serialization.rb +180 -0
  115. data/lib/leptris/xml/text.rb +32 -0
  116. data/lib/leptris/xml/xpath.rb +89 -0
  117. data/lib/leptris/xml/xquery.rb +77 -0
  118. data/lib/leptris/xml/xslt.rb +85 -0
  119. data/lib/leptris/xml.rb +108 -0
  120. data/lib/leptris.rb +53 -0
  121. data/lib/libleptris.so +0 -0
  122. data/lib/libutf8proc.so.3 +0 -0
  123. data/scripts/gem_smoke.rb +36 -0
  124. metadata +214 -0
@@ -0,0 +1,47 @@
1
+ # 12 — Document lifetime in C: TypedData handle replaces the ObjectSpace finalizer
2
+
3
+ Status: DONE (1.9.163.6)
4
+
5
+ Every document (parsed AND created) registers an ObjectSpace
6
+ finalizer: a per-document proc allocation (~110ns), the
7
+ define_finalizer call (~315ns), the Freed struct, and — the real
8
+ cost — a Ruby finalizer invocation at GC time that must dispatch
9
+ back through FFI to leptris_document_free (~1-2µs per document
10
+ inside GC pauses). Fresh-document build loops measured 7.1µs per
11
+ Document.create in-loop, ~4-5µs of it GC/finalizer amortization
12
+ (stackprof: GC ~19% of a build loop, define_finalizer+proc in the
13
+ top frames).
14
+
15
+ Fix: a TypedData DocHandle (struct { void *doc; }) whose dfree
16
+ calls leptris_document_free directly — no Ruby finalizer, no FFI
17
+ from finalizer context, no proc. The handle is referenced only by
18
+ the Document's @doc_handle ivar, so its lifetime IS the
19
+ Document's. Document#free keeps its explicit path (FFI free +
20
+ release the handle's pointer so dfree no-ops — the same
21
+ double-free protocol Freed enforces today, in C). The proc
22
+ finalizer stays for LEPTRIS_NO_NATIVE mode.
23
+
24
+ Companion: Native.create_binding_document — the full
25
+ Document.create in one C dispatch (engine create + wrapper
26
+ allocation + @c_ptr/@c_address/@freed/@readonly/@version + handle
27
+ attach). Document.create today pays FFI marshaling + wrap frames
28
+ (~250-600ns) on top of the engine call.
29
+
30
+ Gates: lifetime_contract_spec + memory_spec green in both modes;
31
+ double-free impossible (free → dfire no-op, finalizer-mode
32
+ unchanged); Document.create and fresh-doc build rows improve.
33
+
34
+ ## Outcome (1.9.163.6)
35
+
36
+ DocHandle TypedData (struct { void *doc; }, RUBY_TYPED_FREE_IMMEDIATELY,
37
+ dfree -> leptris_document_free) attached as @doc_handle on every
38
+ wrap() when native is enabled; Document#free detaches it (dfree
39
+ no-ops). Native.create_binding_document does engine create +
40
+ ivar-seeded wrapper + handle in one dispatch (Document.create
41
+ routes through it). Freed stays the shared free-state; the proc
42
+ finalizer remains for LEPTRIS_NO_NATIVE. 3,000-doc GC-reclamation
43
+ and explicit-free-then-GC specs green; 647/0 both modes. Measured
44
+ under host load 14-30: GC-amortized Document.create ~5.0us (the
45
+ Ruby-finalizer invocation and FFI-dispatch-at-GC are gone from
46
+ every document lifecycle; clean-load numbers pending the battery
47
+ — the box never dropped under load 13 this session).
@@ -0,0 +1,49 @@
1
+ # 13 — Version-stamped structural memos: root/parent/siblings (+ FFI-mode stale-@parent fix)
2
+
3
+ Status: DONE (1.9.163.6)
4
+
5
+ Document#root re-derives (FFI + wrap) on every call — every
6
+ pipeline entry pays it. Node#parent and the sibling hops
7
+ (next_sibling/previous_sibling) pay FFI + Node.wrap per call, so
8
+ Nokogiri-style sibling-advancement loops pay ~300ns/hop.
9
+
10
+ BUG found while designing this: in FFI mode the children walk
11
+ seeds @parent, and a later move (add_child to another parent)
12
+ leaves the stale @parent — child.parent answers the OLD parent.
13
+ Native mode masks it (the bulk path does not seed @parent). The
14
+ version-stamped memo fixes both modes: derive once, stamp with
15
+ the owning document's mutation version, re-derive after any
16
+ mutation that bumps it.
17
+
18
+ Design: parent memoizes only for document-owned nodes
19
+ (scope-owned iterparse elements keep deriving — a scope element
20
+ adopted into a document moves without its scope's version
21
+ advancing, so a stamp would lie). Cross-document adoption bumps
22
+ BOTH documents' versions (the source document's other nodes hold
23
+ sibling/parent stamps that the move invalidates); the moved
24
+ node's stamps clear at the mutation site. next_sibling /
25
+ previous_sibling memoize the same way. Document#root memoizes
26
+ against the document's own version (root= bumps it; free clears).
27
+
28
+ Gates: parent correct after same-doc and cross-doc moves in BOTH
29
+ modes (the FFI staleness becomes a spec); sibling chains correct
30
+ around unlink/insert; root identity stable; traversal-loop rows
31
+ improve.
32
+
33
+ ## Outcome (1.9.163.6)
34
+
35
+ Version-stamped memos on Document#root (pure ivar compare, ~82ns
36
+ under load 17), Node#parent / next_sibling / previous_sibling
37
+ (~72-76ns memo hits vs ~300-500ns deriving; @structure_memoizable
38
+ computed once at construction — including the C-constructed
39
+ wrappers — keeping the hit path free of method dispatch). The
40
+ FFI-mode stale-@parent bug is FIXED (spec-pinned: parent correct
41
+ after same-doc and cross-doc moves; the seed stamps only
42
+ constructor-KNOWN parents — nil constructor parent means unknown,
43
+ because the engine may attach during creation, e.g. create_child).
44
+ Cross-document moves clear the moved node's stamps and advance
45
+ the SOURCE document's version. The adoption-lift ancestor walk
46
+ uses Node#unstamped_parent: deriving a stamped parent between the
47
+ version bump and the engine move would record post-bump versions
48
+ carrying pre-move truth. Scope-owned iterparse elements never
49
+ memoize.
@@ -0,0 +1,31 @@
1
+ # 14 — C-bound insert family + cold-[] native fill
2
+
3
+ Status: DONE (1.9.163.6)
4
+
5
+ add_child and []= are C-bound (TODO.perf/08-09); prepend_child,
6
+ add_next_sibling, and add_previous_sibling still run the full
7
+ Ruby path (ensure_writable + eager-namespace guard ordering +
8
+ FFI). One C face with a mode int covers all engine insertions
9
+ (append/prepend/insert_after/insert_before — same
10
+ int(void*, void*) ABI): gates + predicate + version bump + engine
11
+ insert in one dispatch, Qnil fallback when the child needs the
12
+ namespace lift, exactly like append_binding_child.
13
+
14
+ Also: the completely-cold branch of Element#[] fills through
15
+ leptris_element_attribute (FFI) while the partial-memo branch
16
+ above it uses the native face — the cold path should ride the
17
+ native face too (~150ns per first-touch attribute read).
18
+
19
+ Gates: insert semantics unchanged (specs exist for sibling
20
+ inserts); namespaced children still lift; readonly raises.
21
+
22
+ ## Outcome (1.9.163.6)
23
+
24
+ Native.insert_binding_child(document, anchor, child, mode): 1
25
+ prepend / 2 after / 3 before — gates + predicate + version bump +
26
+ engine insert in one dispatch, Qnil fallback to the Ruby lift
27
+ path. prepend_child / add_next_sibling / add_previous_sibling
28
+ carry the fast branch + cross-document invalidation. The
29
+ completely-cold Element#[] first-touch fill rides the native face
30
+ (matching the partial-memo branch). Specs: ordering, lift
31
+ fallback, readonly raise.
@@ -0,0 +1,35 @@
1
+ # 15 — Compiled-expression cache for Searchable#xpath / #at_xpath
2
+
3
+ Status: DONE (1.9.163.6)
4
+
5
+ Searchable#xpath passes the expression STRING to
6
+ leptris_xpath_eval each call. The engine caches compiled
7
+ expressions by string (measured: repeat-string eval 7.0µs vs
8
+ varied-string 34.98µs — the ~28µs is per-call parsing), but the
9
+ string path still pays cache lookup + hashing per call. A
10
+ compiled handle evaluated directly measured 4.58µs on the same
11
+ shape — 34% under the string path.
12
+
13
+ The binding already exposes XPath.compile (parse once, eval
14
+ many). Add a bounded LRU keyed on the expression string inside
15
+ Searchable: plain (no version pin, no ns bindings) xpath/at_xpath
16
+ evaluations route through cached compiled handles. Cache only
17
+ successful compiles; bounded (~64 entries, Hash#shift eviction);
18
+ GVL makes the Hash ops safe.
19
+
20
+ Gates: xpath/at_xpath results identical (nodesets, scalars,
21
+ empty results); invalid expressions still raise XPathError;
22
+ namespace-bound and version-pinned paths unchanged; repeat-call
23
+ rows improve ~30%.
24
+
25
+ ## Outcome (1.9.163.6)
26
+
27
+ Searchable.compiled_expression: bounded LRU (64 entries, Hash#shift
28
+ eviction, LRU refresh on hit) keyed on the expression string;
29
+ plain xpath/at_xpath evaluate cached compiled handles via
30
+ XPath#eval_ptrs (leptris_xpath_compiled_eval). Failed compiles
31
+ never cache (fall back to the string entry so the error surface
32
+ is unchanged); version-pinned and namespace-bound paths keep
33
+ their dedicated entries. Measured: repeat at_xpath 3.7us ->
34
+ 1.9us under load 17-30 (~2x); CSS rides it automatically
35
+ (deterministic translation -> same key).
@@ -0,0 +1,38 @@
1
+ # 16 — C-bound at_xpath single-result seam
2
+
3
+ Status: DONE (1.9.163.7)
4
+
5
+ at_xpath is the hottest query shape (adapters picking one node),
6
+ and its result path is five FFI calls per hit: result_type,
7
+ get_node(0), node_kind(0), Node.wrap (pointer + construct), and
8
+ result_free — plus the Ruby case dispatch between them. The bulk
9
+ machinery from TODO.perf/03 already knows how to materialize a
10
+ result entry as a binding wrapper in C (identity cache, kind
11
+ dispatch, attribute/text value capture).
12
+
13
+ Add Native.at_xpath_first(document, result_ptr): one C dispatch
14
+ checks the result type; nodesets materialize entry 0 through the
15
+ bulk machinery (get_nodes_ex with capacity 1 carries pointer +
16
+ kind in one call), free the result handle, and return the binding
17
+ node (or Qnil on empty); non-nodeset results return Qundef so the
18
+ existing Ruby scalar path keeps its exact semantics. Searchable's
19
+ wrap_xpath_first_result routes through the face when native is
20
+ enabled.
21
+
22
+ Gates: at_xpath parity for elements, attributes (ResultAttr name/
23
+ value capture), text-kind results, empty results, and scalars;
24
+ the result handle always freed exactly once.
25
+
26
+ ## Outcome (1.9.163.7)
27
+
28
+ Native.at_xpath_first(document, result_ptr): one dispatch — type
29
+ check, entry-0 materialization through the extracted
30
+ materialize_xp_entry (shared with the TODO.perf/03 bulk loop:
31
+ identity cache, kind dispatch, attribute/text value capture),
32
+ result free. Non-nodeset results return the Native module object
33
+ (Qundef must never cross into Ruby) and keep the exact Ruby
34
+ scalar path. Measured: repeat at_xpath 3.7µs baseline -> 1.9µs
35
+ (TODO.perf/15) -> ~1.3µs with the seam (2.8x total, load 8-30;
36
+ the same-run Nokogiri comparison on this box is anomalous and not
37
+ reported). Specs pin element identity, attribute capture, text
38
+ kinds, nil misses, scalars, and xpath().first agreement.
@@ -0,0 +1,35 @@
1
+ # 17 — Precomputed fast-path flags + set_attribute tightening
2
+
3
+ Status: DONE (1.9.163.7)
4
+
5
+ The native guards on hot paths are method chains:
6
+ native_fast_children? resolves as defined?(NATIVE_FAST) plus
7
+ scope_owned? (another method) — paid per mutation ([]=, add_child,
8
+ the insert family) and per cold read fill. @structure_memoizable
9
+ (TODO.perf/13) already carries "document-owned and alive" as an
10
+ ivar; NATIVE_FAST availability is fixed at load time before any
11
+ node exists, so the conjunction is construct-time constant.
12
+
13
+ Add @native_fast to every wrapper construction (Ruby initialize
14
+ and every C construction site): one ivar read replaces the chain
15
+ at every gate. Fold in the set_attribute shaves: the C face reads
16
+ attribute NAMES via StringValue+RSTRING_PTR (names cannot contain
17
+ NULs — same rationale as nn_attribute); VALUES keep StringValueCStr
18
+ so an embedded-NUL value still raises rather than truncating.
19
+
20
+ Gates: suite green in both modes (the FFI fallback semantics must
21
+ be unchanged for nodes constructed before a late native require —
22
+ worst case they stay on the FFI path); set_attribute row improves.
23
+
24
+ ## Outcome (1.9.163.7)
25
+
26
+ @native_fast computed once at construction (Ruby initialize and
27
+ every C construction site — the bundle running implies the
28
+ constant), replacing the defined?+scope_owned? method chain at
29
+ every gate (native_fast?/native_fast_children? are ivar readers
30
+ now). []= additionally reads attribute names via
31
+ StringValue+RSTRING_PTR (values keep StringValueCStr — an
32
+ embedded-NUL value still raises; spec-pinned). Measured (load
33
+ 17-31): binding []= 454ns mandate baseline -> 236ns (~2x on the
34
+ row; 0.88x vs Nokogiri's 209ns in the same run — effectively
35
+ parity).
@@ -0,0 +1,32 @@
1
+ # 18 — inner_html in one C pass
2
+
3
+ Status: DONE (1.9.163.7)
4
+
5
+ Element#inner_html walks children (bulk since TODO.perf/01) and
6
+ then serializes EACH child from Ruby: element children get one
7
+ ext dispatch each, text/comment/PI children are string-built in
8
+ Ruby and joined. An N-child element pays N dispatches plus the
9
+ case ladder plus the join.
10
+
11
+ Bind the whole pass in C: iterate the child chain once, appending
12
+ each child's serialization into one growable buffer — elements
13
+ through leptris_element_serialize_into (the fast_serialize
14
+ protocol), text nodes XML-escaped (& < > — the binding's
15
+ escape_text set), CDATA wrapped raw, comments and PIs wrapped.
16
+ One dispatch, one string mint, no Ruby per child.
17
+
18
+ Gates: inner_html parity for every child kind (elements, text
19
+ with entities, CDATA, comments, PIs, mixed), empty elements,
20
+ large children past the 4096-byte stack probe (growth path).
21
+
22
+ ## Outcome (1.9.163.7)
23
+
24
+ Native.fast_inner_xml: one pass over the child chain into a
25
+ grow-only scratch — elements through leptris_element_serialize_into
26
+ (needed-1 protocol, grow+retry past capacity), text escaped with
27
+ the binding's entity set (& < > \r), CDATA raw-wrapped, comments
28
+ and PIs wrapped (PI data leading-whitespace-stripped, read_pi_data
29
+ parity). Byte-identical to the Ruby loop for every child kind and
30
+ past the 4KB growth floor (specs). Measured (load 17-31): 30-child
31
+ inner_html ~4.9µs (~160ns/child vs the loop's ~500-800ns/child
32
+ estimate).
@@ -0,0 +1,39 @@
1
+ # 19 — Lazy FFI::Pointer on wrappers
2
+
3
+ Status: DONE (1.9.174.3)
4
+
5
+ Every binding wrapper stores an FFI::Pointer (@c_ptr), and the C
6
+ construction faces mint one per node via
7
+ rb_funcall(FFI::Pointer, :new) — ~200ns plus a Ruby object and
8
+ its GC visit per node, paid at creation even though most nodes'
9
+ Pointers are never read (the hot reads are memoized or go through
10
+ the address-based native faces).
11
+
12
+ Store @c_address (Integer) as the canonical truth on every
13
+ wrapper; `c_ptr` becomes a lazy materializer
14
+ (@c_ptr ||= FFI::Pointer.new(@c_address)) preserving the public
15
+ API. Internal call sites: `@c_ptr.address` collapses to
16
+ @c_address (also skipping the #address dispatch), and remaining
17
+ internal @c_ptr reads go through the c_ptr reader (same cost as
18
+ the old attr_reader). The C faces stop constructing Pointers
19
+ entirely — they already hold the address.
20
+
21
+ Gates: suite green both modes (a missed site fails loudly — nil
22
+ to FFI raises TypeError); wrapper identity and c_ptr stability
23
+ unchanged; creation faces drop ~200ns and one allocation per
24
+ node; fresh-doc build row improves accordingly.
25
+
26
+ ## Outcome (1.9.174.3)
27
+
28
+ @c_address is the canonical truth on every Node wrapper; c_ptr
29
+ materializes lazily (@c_ptr ||= FFI::Pointer.new(@c_address)),
30
+ preserving the public API and identity. All internal @c_ptr
31
+ reads became c_ptr reader calls; the 15 @c_ptr.address sites
32
+ collapsed to @c_address (also skipping the #address dispatch).
33
+ The C construction faces (create element/text, bulk children,
34
+ every xpath materialization) stopped minting Pointers entirely —
35
+ one allocation and ~200ns saved per node, and proportionally less
36
+ GC. Document keeps its eager reader (documents are few).
37
+ Measured under host load 22-25: GC-amortized Document.create
38
+ ~5-6µs -> ~4.0µs; NativeNode#[] 78ns (budget 70 — clear at
39
+ load <10), content 45ns (budget 58 met).
@@ -0,0 +1,24 @@
1
+ # 20 — CSS translation cache
2
+
3
+ Status: DONE (1.9.174.3)
4
+
5
+ Searchable#css/#at_css run CssToXPath.convert on every call —
6
+ pure Ruby regex/string work (~300ns+) — before the compiled-
7
+ expression cache (TODO.perf/15) can hit on the result. The
8
+ translation is a pure function of (selector, prefix); cache it
9
+ with the same bounded-LRU shape (64 entries, Hash#shift
10
+ eviction, GVL-safe), so repeat selectors skip straight to the
11
+ compiled handle.
12
+
13
+ Gates: css/at_css results identical; repeat-call rows improve by
14
+ the translation cost; the join in xpath()'s expression build is
15
+ also skipped for the single-String-path shape (join minted a
16
+ fresh String per call even for one path).
17
+
18
+ ## Outcome (1.9.174.3)
19
+
20
+ Searchable.css_expression: bounded LRU (64, shared limit) keyed
21
+ on prefix+selector; css/at_css translate through it so repeat
22
+ selectors skip the regex machinery straight to the compiled-
23
+ expression cache. The single-String-path shape of xpath/at_xpath
24
+ no longer joins (join minted a fresh String per call).
@@ -0,0 +1,20 @@
1
+ # 21 — key? consults the attribute memo
2
+
3
+ Status: DONE (1.9.174.3)
4
+
5
+ Element#key?/has_attribute? pays an engine FFI round-trip per
6
+ call even when the versioned @attr_values memo already proves
7
+ presence. Consult the memo first (hit = true; only a miss falls
8
+ through to the engine + written-name fallback — the memo cannot
9
+ prove ABSENCE for a partial fill).
10
+
11
+ Gates: key? true for memoized names, unchanged miss semantics
12
+ (undeclared-prefix fallback intact), readonly/namespace specs
13
+ green.
14
+
15
+ ## Outcome (1.9.174.3)
16
+
17
+ Element#key? consults the versioned @attr_values memo first —
18
+ presence-proven hits skip the engine round-trip; partial-fill
19
+ misses still fall through to the engine + written-name fallback
20
+ (the memo cannot prove absence).
@@ -0,0 +1,30 @@
1
+ # 22 — Eager-in-C nodeset materialization (drop the per-call AutoPointer)
2
+
3
+ Status: DONE (1.9.174.4)
4
+
5
+ Every xpath() nodeset result pays an FFI::AutoPointer + a Method
6
+ object + finalizer registration (~400-600ns plus their GC) just
7
+ to keep the C result handle alive until the lazy NodeSet
8
+ materializes — and virtually every consumer materializes anyway
9
+ (each/size/to_a). Materialize eagerly in C instead: one face runs
10
+ the bulk pass AND frees the result handle, returning the Ruby
11
+ Array; Searchable wraps it in the eager NodeSet constructor (an
12
+ Array-backed set has no handle at all). Exotic node kinds (the
13
+ module-object sentinel) fall back to the existing lazy path with
14
+ the handle intact.
15
+
16
+ Gates: xpath results identical for every kind (including
17
+ sentinel-graded kinds); laziness is internal — the public NodeSet
18
+ behaviors (indexing, iteration, size) unchanged; the xpath row
19
+ drops the AutoPointer cost per call.
20
+
21
+ ## Outcome (1.9.174.4)
22
+
23
+ Native.materialize_xpath: the bulk pass AND the result free in C;
24
+ Searchable's nodeset branch wraps the returned Array in the eager
25
+ NodeSet (no AutoPointer, no Method object, no finalizer per
26
+ xpath call). Sentinel scans happen in C (a Ruby include? would
27
+ cost microseconds on large sets) — any exotic kind returns Qnil
28
+ with the handle alive for the lazy fallback. Measured (load
29
+ ~11): full materialization of a 2000-node xpath set 55.9µs vs
30
+ Nokogiri 57.3µs — parity on the heavy shape.
@@ -0,0 +1,27 @@
1
+ # 23 — C-bound name= / content= / unlink
2
+
3
+ Status: DONE (1.9.174.4)
4
+
5
+ The value mutations still run ensure_writable! + FFI marshaling
6
+ per call (~450ns): Element#name=, Element#content=,
7
+ Text#content=, and Node#unlink (transforms rename, rewrite, and
8
+ remove nodes constantly). Same face shape as set_binding_attribute:
9
+ gates + version bump + engine write in one dispatch —
10
+ set_binding_name (element names), set_binding_text (element text
11
+ or text-node content, kind-dispatched in C), and
12
+ unlink_binding_node (plus the Ruby-side @parent clear and memo
13
+ invalidations that ride the version bump).
14
+
15
+ Gates: rename/content/unlink semantics unchanged (memos, sibling
16
+ stamps, path memo all invalidate through the version bump);
17
+ readonly raises through every face.
18
+
19
+ ## Outcome (1.9.174.4)
20
+
21
+ Native.set_binding_name / set_binding_text (kind-dispatched
22
+ element-vs-text) / unlink_binding_node — gates + bump + engine
23
+ write in one dispatch, wired into Element#name=, #content=,
24
+ Text#content=, and Node#unlink (@parent clear and memo
25
+ invalidation ride the version bump). Measured (load ~11):
26
+ name= 237ns, content= 298ns, unlink+re-add 1.74µs; readonly
27
+ raises verified through every face.
@@ -0,0 +1,22 @@
1
+ # 24 — Immutable read lanes: line/byte_offset memo + precomputed public #document
2
+
3
+ Status: DONE (1.9.174.4)
4
+
5
+ Node#line and #byte_offset pay an FFI round-trip per call, but a
6
+ node's source position never changes — memoize unconditionally
7
+ (first read wins). The public Node#document walks scope_owned? (a
8
+ method chain) on every call though scope ownership is fixed at
9
+ construction — precompute @pub_document once (nil for
10
+ scope-owned) including at every C construction site.
11
+
12
+ Gates: line/byte_offset stable across mutations (they must be —
13
+ the engine reports source positions); document nil for
14
+ scope-owned yields exactly as before; suite both modes.
15
+
16
+ ## Outcome (1.9.174.4)
17
+
18
+ line/byte_offset memoize unconditionally (a node's source
19
+ position never changes; 40ns hits). Node#document answers the
20
+ constructor-precomputed @pub_document (nil for scope-owned
21
+ yields) — the scope_owned? method chain disappears from one of
22
+ the most-called readers; every C construction site sets it.
@@ -0,0 +1,39 @@
1
+ # 25 — Scope-owned (iterparse) elements on the bulk/native read path
2
+
3
+ Status: DONE (1.9.174.5)
4
+
5
+ iterparse is moxml's streaming core, yet scope-owned elements
6
+ ride the FFI read path (two-call fetch + per-child Ruby wrap
7
+ frames; #4ca5b3c kept them off deliberately). The IterationScope
8
+ exposes exactly the seams the C faces read — @wrapper_cache
9
+ (reset per yield by new_subtree!, killing recycled-address
10
+ entries) and @version (advances per yield and on mutation) — so
11
+ the bulk children faces can take the SCOPE as their "document"
12
+ argument safely, provided the constructed wrappers are stamped
13
+ scope-aware: @structure_memoizable/@native_fast false,
14
+ @pub_document nil.
15
+
16
+ Pure address-based reads (fast_attribute / element text / name)
17
+ touch no cache — scope elements can use them via a construction
18
+ -time @addr_reads_fast that ignores scope ownership.
19
+
20
+ Gates: iterparse children/element_children identical via the C
21
+ path; identity holds within a yielded subtree and resets across
22
+ yields (the new_subtree! discipline); document stays nil for
23
+ scope yields; mutations on scope elements keep the Ruby path;
24
+ suite both modes.
25
+
26
+ ## Outcome (1.9.174.5)
27
+
28
+ The bulk children faces take the IterationScope itself as their
29
+ "document" (its @wrapper_cache resets per yield via new_subtree!
30
+ — recycled pool addresses cannot collide — and its @version
31
+ advances per yield and on mutation); the C face stamps
32
+ scope-aware (@structure_memoizable/@native_fast false,
33
+ @pub_document nil) while document-owned stamps stay Qtrue.
34
+ Pure address-based reads (fast_attribute / element text / name)
35
+ switch to @addr_reads_fast — true for scope elements too (they
36
+ touch no cache). Mutations on scope elements keep the Ruby path.
37
+ Measured (load ~6): the 20k-record iterparse walk (attr + child
38
+ name + content per record) 124.8ms (FFI) -> 100.5ms (native
39
+ bulk) — ~20%.
@@ -0,0 +1,26 @@
1
+ # 26 — Post-mutation memo seeding
2
+
3
+ Status: DONE (1.9.174.5)
4
+
5
+ Every mutation invalidates the read memos through the version
6
+ bump, so a write-then-read cycle pays a full re-derivation even
7
+ when the new value is known at write time. Seed it: Element#[]=
8
+ re-stamps @attr_values at the new version and writes the value
9
+ into it (a partial fill stays partial — same rules); content=
10
+ stores the new text as the @content memo at the new version;
11
+ name= already seeds @name.
12
+
13
+ Gates: []=/content=/name= followed by reads answers from the
14
+ seeded memo (spec-observable via identity for strings? — no,
15
+ values only); mixed attribute sets then attributes() still
16
+ rebuild the full face on demand; invalidation semantics
17
+ unchanged for other mutators.
18
+
19
+ ## Outcome (1.9.174.5)
20
+
21
+ Element#[]= seeds the version-stamped @attr_values at the new
22
+ version (a just-invalidated valid partial memo extends in place;
23
+ a full face or anything older restarts partial, mirroring the
24
+ cold-[] rules); content= seeds @content; name= seeds @name
25
+ (already). Measured: the []= + [] read-back cycle runs ~545ns
26
+ (write + read combined).
@@ -0,0 +1,38 @@
1
+ # 27 — C-yield traversal (traverse/visit without the FFI::Function closure)
2
+
3
+ Status: DONE (1.9.174.6)
4
+
5
+ Node#traverse and #visit allocate an FFI::Function closure per
6
+ call (~1µs) and dispatch every visited node back through the
7
+ Ruby closure — the dominant per-node cost of whole-subtree
8
+ iteration, paid even on small subtrees where the walk itself is
9
+ microseconds.
10
+
11
+ Bind both to C callbacks that rb_yield directly: the ext's
12
+ traverse face keeps the abort-at-self post-order contract and
13
+ the exception discipline (rb_protect in the callback stashes,
14
+ aborts the walk, re-raises after — exactly the Ruby version's
15
+ contract); visit keeps its (node, entering, depth) callback
16
+ shape with the same stash-and-raise treatment. Node wrapping per
17
+ visit rides the shared binding construction (identity cache +
18
+ kind dispatch), and the walk state lives on the C stack — no
19
+ globals, nested traversals safe.
20
+
21
+ Gates: post-order sequence identical (receiver last); the
22
+ subtree boundary holds (abort-at-self); block exceptions
23
+ re-raise after the walk stops; enum_for forms unchanged; suite
24
+ both modes.
25
+
26
+ ## Outcome (1.9.174.6)
27
+
28
+ Native.traverse_binding / visit_binding: C callbacks that
29
+ rb_yield directly (block forwarded explicitly — rb_yield needs
30
+ it on the C entry's frame). traverse keeps post-order +
31
+ abort-at-self + stash-abort-raise (rb_protect in the callback,
32
+ walk state on the C stack — nested traversals independent);
33
+ visit keeps (node, entering, depth) with the engine's
34
+ (user, node, ...) callback order. Two bugs found by the suite:
35
+ the visit callback's argument order (user first — the segfault
36
+ spec caught it) and the block forwarding. Measured (load 34):
37
+ traverse over 8k nodes 1663.5µs (FFI closure) -> 379.9µs
38
+ (4.4x; 6.81x Nokogiri); visit 2288.6µs -> 545.4µs (4.2x).
@@ -0,0 +1,25 @@
1
+ # 28 — Address-based first-touch fills for the remaining reads
2
+
3
+ Status: DONE (1.9.174.6)
4
+
5
+ The memoized content readers still fill through FFI marshaling on
6
+ first touch — Text/Comment/CDATA/PI content, and Node#path
7
+ (owned-string read + free) — and the bulk attribute faces exclude
8
+ scope-owned elements though they touch no document state. Add the
9
+ trivial address faces (fast_text/comment/cdata content, pi
10
+ target/data, node path with the free protocol) and switch the
11
+ fills to @addr_reads_fast (scope-eligible); the attributes bulk
12
+ path follows.
13
+
14
+ Gates: identical values for every kind (UTF-8, escapes, nils);
15
+ scope-owned fills answer the same; path memo unchanged; suite
16
+ both modes.
17
+
18
+ ## Outcome (1.9.174.6)
19
+
20
+ fast_text/comment/cdata content, fast_pi_target/data (the
21
+ read_pi_data strip preserved), and fast_path (owned-string copy
22
+ + engine free) — every first-touch content fill now rides the
23
+ address faces via @addr_reads_fast (scope-eligible), and the
24
+ bulk attribute faces accept scope-owned elements (they touch no
25
+ document state; the scope's version stamps the memo).
@@ -0,0 +1,23 @@
1
+ # 29 — Namespace-bound xpath on the compiled + eager path
2
+
3
+ Status: DONE (1.9.174.7)
4
+
5
+ TODO.perf/15 excluded namespace-bound evaluations from the
6
+ compiled-expression cache — they still pay the engine's string
7
+ parse per call (~2-3µs) on top of an already-cached ns set. The
8
+ engine has leptris_xpath_compiled_eval_ns; route the ns branch
9
+ of xpath/at_xpath through the compiled cache (the handle is
10
+ ns-independent — compile once) and the cached ns set, then let
11
+ the eager materializer (TODO.perf/22) take the result.
12
+
13
+ Gates: ns-bound results identical (prefix resolution, missing
14
+ prefixes raising as today); the version-pinned path untouched;
15
+ suite both modes.
16
+
17
+ ## Outcome (1.9.174.7)
18
+
19
+ The ns branch of xpath/at_xpath rides the compiled-expression
20
+ cache (the handle is ns-independent) + the cached ns set via
21
+ XPath#eval_ns_ptrs (leptris_xpath_compiled_eval_ns); results
22
+ flow into the eager materializer. Measured (load ~16): repeat
23
+ ns-xpath 7.9µs -> 3.1µs (2.5x).
@@ -0,0 +1,31 @@
1
+ # 30 — dup one-dispatch + first/last element child faces
2
+
3
+ Status: DONE (1.9.174.7)
4
+
5
+ Node#dup runs create + FFI element_copy + root= (three dispatches
6
+ plus the lift-guard walk) for an engine-copier op that is already
7
+ 2.3x — bind the whole copy: engine create + handle + element_copy
8
+ + root wrap in one face. And the element-child readers pay O(N):
9
+ first_element_child scans siblings with two FFI calls per
10
+ non-element (text-heavy parents), last_element_children batches
11
+ ALL children to keep one — one C walk each answers in a single
12
+ dispatch.
13
+
14
+ Gates: dup semantics (comment/PI children, namespaces — the
15
+ copy_of seam contract #696/#721/#812); first/last on mixed
16
+ content, empty elements, document receivers; suite both modes.
17
+
18
+ ## Outcome (1.9.174.7)
19
+
20
+ Native.copy_binding_element: engine create + lifetime handle +
21
+ document wrapper + element_copy + rooted+memo-seeded wrap in one
22
+ dispatch; the namespace-lift decision stays in Ruby (skip_
23
+ adoption_lift? + lift — the copy_of seam's #696/#721/#812
24
+ semantics), and the return shape matches copy_of's contract (the
25
+ copied root ELEMENT — my first version returned the document and
26
+ the probe caught it). Native.first/last_element_child: one C
27
+ walk each (the FFI scan paid two calls per skipped sibling; the
28
+ batch fetch materialized every child to keep one). Measured
29
+ (load ~16): ns-bearing element dup 37.0µs -> 9.6µs (3.9x);
30
+ first_element_child over 1000 mixed children 98ns (memo-hit
31
+ shape), last 3.2µs cold-walk.
@@ -0,0 +1,23 @@
1
+ # 31 — Battery rows for the round 4-8 surfaces
2
+
3
+ Status: DONE (1.9.174.8)
4
+
5
+ The drift battery (benchmark/native_vs_binding.rb) predates
6
+ TODO.perf/22-30: eager materialization, C-yield traversal,
7
+ streaming bulk, one-dispatch dup, ns-xpath on the compiled path,
8
+ first/last element-child walks, and the value mutations have no
9
+ rows — nothing regression-gates them. Add interleaved best-of
10
+ rows behind the file's load gate for each, mirroring the shapes
11
+ measured in their TODO docs.
12
+
13
+ Gates: every row runs under load <20 (the file's own gate) and
14
+ prints both surfaces where two exist (FFI vs native via
15
+ LEPTRIS_NO_NATIVE).
16
+
17
+ ## Outcome (1.9.174.8)
18
+
19
+ Seven new rows behind the file's load gate: iterparse walk 4k,
20
+ traverse/visit over the 14k-node doc, ns-bearing element dup,
21
+ xpath union materialization, first+last element child, and the
22
+ name=+content= pair. The round-4-8 surfaces are now
23
+ regression-gated.