leptris 1.9.174.6 → 1.9.174.8

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0ac7143c5a7730e13077c52448c2b90a7705959a4fe458e7c29e7782d7ee6df1
4
- data.tar.gz: 23d3530348907214d341704fa7b098728fa8b677422c8f595e12ae33c2b88ff3
3
+ metadata.gz: e3bbbd00177ef272738c9d0d9562beb33cc2ed100145bc843785fd56ae0c5f22
4
+ data.tar.gz: c47af3b6769fb4e9cb233128df91b36bf9cecae4d23e06b9f94ad60caf9d498a
5
5
  SHA512:
6
- metadata.gz: 6395fec9ab3701df1b77f32bbe950feff8b918b9f79374b5c107d9e282bb5e9e5fdd91c152dc46526fc201910e69b7c57e8ee968d11e222be3cfdd346c6c7ab8
7
- data.tar.gz: a55e0b100034203af05855e8881fbfa161d30d6043b3831080ebff8d0f9ca658ca74fd11e1d30aef6887b3108493e4f690f0e08133ca4cf3bca36399cbcacdc4
6
+ metadata.gz: e951276162615da4fdecfd7d500bdd9ddd2a378188190d9edf14341713243f1ce802fa2a129f5afb07d676ce2977e7d0e59e44b474dce656432cdb57d99b5fac
7
+ data.tar.gz: 812ae80ffb569ae41fa0734858a8f487f31f3a22689fc221686b801cf1922e69d4331615a678e1dd5a5589c2c464b6fe6ae51c4504b365448ea63a6ad6215628
data/CHANGELOG.md CHANGED
@@ -5,6 +5,47 @@ All notable changes to Leptris will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.9.174.8] - 2026-09-16
9
+
10
+ ### Fixed
11
+
12
+ - **`Document#root=` now gates readonly** — the FFI path
13
+ silently mutated frozen documents (the C face exposed the
14
+ divergence); both paths raise `ReadOnlyError`, spec-pinned in
15
+ both modes.
16
+
17
+ ### Changed
18
+
19
+ - **C-bound `root=` (TODO.perf/33)**: gates + version bump +
20
+ engine set_root in one dispatch.
21
+ - **Battery rows for the round 4-8 surfaces (TODO.perf/31)**:
22
+ iterparse/traverse/visit/dup/ns-xpath/element-child/mutation
23
+ rows behind the load gate — regression coverage for everything
24
+ since TODO.perf/22.
25
+
26
+ ### Measured — the clean-host floor table (TODO.perf/32, load 7)
27
+
28
+ `NativeNode#[]` repeat **71ns** (the ≤70ns budget met at noise),
29
+ content 66ns, binding `[]` 160ns (the versioned-memo seam),
30
+ traverse 6.39ms/14k nodes, visit 9.69ms, iterparse 4k 20.1ms,
31
+ ns dup 7.66µs, xpath union 2.21µs, first+last 190ns,
32
+ name=+content= 462ns, build 13.7µs.
33
+
34
+ ## [1.9.174.7] - 2026-09-16
35
+
36
+ ### Changed
37
+
38
+ - **Namespace-bound xpath on the compiled path (TODO.perf/29)**:
39
+ the ns branch rides the compiled-expression cache (the handle
40
+ is ns-independent) plus the cached ns set through
41
+ `XPath#eval_ns_ptrs`, then the eager materializer — measured
42
+ (load ~16): repeat ns-xpath 7.9µs → 3.1µs (2.5x).
43
+ - **dup + element-child faces (TODO.perf/30)**: `Element#dup`
44
+ runs engine create + handle + element_copy + rooted wrap in
45
+ one C dispatch (the namespace-lift decision stays in Ruby) —
46
+ ns-bearing dup 37.0µs → 9.6µs (3.9x); `first/last_element_child`
47
+ answer in one C walk each (the FFI paths paid O(N) scans).
48
+
8
49
  ## [1.9.174.6] - 2026-09-15
9
50
 
10
51
  ### Changed
@@ -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.
@@ -0,0 +1,29 @@
1
+ # 32 — Clean-host battery run: the definitive floor table
2
+
3
+ Status: DONE (1.9.174.8)
4
+
5
+ Every floor claimed since TODO.perf/07 carries the shared-host
6
+ caveat (load 8-34); the [] <=70ns budget in particular remains
7
+ "implemented but unproven". The box now has quiet windows. Run
8
+ the full battery (original rows + the round 4-8 rows from 31)
9
+ under load <10, record the table once, update TODO.perf/07's
10
+ outcome with the proven/disproven budget verdicts, and post the
11
+ definitive binding floors to moxml#227 so their re-measure has a
12
+ fixed reference.
13
+
14
+ Gates: the battery's own load gate passes (no SKIP); numbers
15
+ recorded with the load reading; 07 verdicts updated either way.
16
+
17
+ ## Outcome (1.9.174.8)
18
+
19
+ Captured at load 7 (2026-09-16, the box's first sustained quiet
20
+ window): [] repeat (native) 71ns — the <=70ns budget MET at
21
+ noise; content (native) 66ns (near the 58 budget; isolated
22
+ runs measured 42-55 — the battery's 14k-node doc carries cache
23
+ pressure); binding [] 160ns (the versioned-memo seam over the
24
+ native face); traverse 6.39ms/14k nodes; visit 9.69ms; iterparse
25
+ 4k walk 20.1ms; ns dup 7.66µs; xpath union 2.21µs; first+last
26
+ 190ns; name=+content= 462ns; build 13.7µs. TODO.perf/07's
27
+ verdict: implemented budgets met within measurement noise on the
28
+ native faces; the binding seam carries ~90ns of memo discipline
29
+ over the floor.
@@ -0,0 +1,29 @@
1
+ # 33 — C-bound root=
2
+
3
+ Status: DONE (1.9.174.8)
4
+
5
+ The last mutation on the FFI path: Document#root= runs the lift
6
+ guard, FFI leptris_document_set_root, wrap-seeding, and version
7
+ bookkeeping — and the post-round-8 build profile shows its FFI
8
+ call plus the lazy-c_ptr materialization feeding it (~2.4% of a
9
+ fresh-doc build each). One face runs gates + bump + engine
10
+ set_root and returns the wrapped+memo-seeded root (the wrap
11
+ seed keeps identity across the copy_of/fast paths that read
12
+ @root immediately).
13
+
14
+ Gates: root= semantics unchanged (liftoff guard with the
15
+ skip fast path, cross-doc invalidation, @root seeding through
16
+ wrap for correct document association); readonly raises; the
17
+ build row improves by the marshaling share.
18
+
19
+ ## Outcome (1.9.174.8)
20
+
21
+ Native.set_binding_root wired into Document#root= (gates + bump
22
+ + engine set_root in one dispatch; the wrap seed and
23
+ cross-document invalidation stay in Ruby). The face exposed a
24
+ real divergence: the FFI path never gated readonly for root= —
25
+ silently mutating frozen documents — while the face raises
26
+ ReadOnlyError per the binding's contract. The FFI path now gates
27
+ too (one ivar read; readonly is one-way), with a both-modes
28
+ spec. Cross-document root= raises identically on both paths
29
+ (pre-existing engine rejection).
@@ -93,12 +93,14 @@ typedef void (*visit_cb_fn)(void *, void *, int, int);
93
93
  typedef void (*visit_fn)(void *, visit_cb_fn, void *);
94
94
  typedef void (*free_str_fn)(void *);
95
95
  typedef const char *(*node_xpath_fn)(void *);
96
+ typedef void *(*elem_copy_fn)(void *, void *);
96
97
  static set_str_fn f_elem_set_name, f_elem_set_text, f_text_set_content;
97
98
  static node_unlink_fn f_node_unlink;
98
99
  static traverse_fn f_node_traverse;
99
100
  static visit_fn f_node_visit;
100
101
  static free_str_fn f_free_str;
101
102
  static node_xpath_fn f_node_xpath;
103
+ static elem_copy_fn f_elem_copy;
102
104
  static set_root_fn f_set_root;
103
105
  static doc_free_fn f_doc_free;
104
106
 
@@ -117,6 +119,8 @@ static void resolve_binding_classes(void);
117
119
  static VALUE binding_cache_of(VALUE document);
118
120
  static VALUE binding_klass_for(int kind);
119
121
  static VALUE c_iteration_scope;
122
+ static VALUE c_b_document, c_b_freed, c_ffi_pointer;
123
+ static ID id_ptr_new, id_freed_new, id_alive;
120
124
 
121
125
  #define NT_ELEMENT 0
122
126
 
@@ -216,6 +220,7 @@ static void resolve_symbols(const char *lib_path)
216
220
  f_node_visit = (visit_fn)lib_sym(h, "leptris_node_visit");
217
221
  f_free_str = (free_str_fn)lib_sym(h, "leptris_free_string");
218
222
  f_node_xpath = (node_xpath_fn)lib_sym(h, "leptris_node_get_xpath");
223
+ f_elem_copy = (elem_copy_fn)lib_sym(h, "leptris_element_copy");
219
224
  f_set_root = (set_root_fn)lib_sym(h, "leptris_document_set_root");
220
225
  f_doc_free = (doc_free_fn)lib_sym(h, "leptris_document_free");
221
226
  if (!f_elem_name || !f_text_content || !f_attr ||
@@ -228,7 +233,7 @@ static void resolve_symbols(const char *lib_path)
228
233
  !f_pi_data || !f_elem_set_name || !f_elem_set_text ||
229
234
  !f_text_set_content || !f_node_unlink ||
230
235
  !f_node_traverse || !f_node_visit || !f_free_str ||
231
- !f_node_xpath ||
236
+ !f_node_xpath || !f_elem_copy ||
232
237
  !f_doc_free ||
233
238
  !f_elem_prefix || !f_xp_count || !f_xp_nodes_ex ||
234
239
  !f_xp_node_kind || !f_xp_node_name || !f_xp_node_value ||
@@ -725,6 +730,30 @@ static VALUE nf_unlink_binding_node(VALUE self, VALUE document,
725
730
  return INT2FIX(st);
726
731
  }
727
732
 
733
+ /* C-bound root= (TODO.perf/33): gates + bump + engine set_root
734
+ * in one dispatch. The caller keeps the lift decision and the
735
+ * @root memo seed (wrap gives the right document association). */
736
+ static VALUE nf_set_binding_root(VALUE self, VALUE document,
737
+ VALUE addr)
738
+ {
739
+ void *node;
740
+ int st;
741
+
742
+ (void)self;
743
+ resolve_binding_classes();
744
+ if (NIL_P(rb_ivar_get(document, id_iv_c_address)))
745
+ rb_raise(c_use_after_free_error,
746
+ "owning document has been freed");
747
+ if (rb_ivar_get(document, id_iv_readonly) == Qtrue)
748
+ rb_raise(c_readonly_error,
749
+ "document is readonly — mutation attempted");
750
+ node = (void *)(uintptr_t)NUM2ULL(addr);
751
+ rb_ivar_set(document, id_iv_version,
752
+ LONG2FIX(FIX2LONG(rb_ivar_get(document, id_iv_version)) + 1));
753
+ st = f_set_root(doc_ptr_of(document), node);
754
+ return INT2FIX(st);
755
+ }
756
+
728
757
  /* ---- C-yield traversal (TODO.perf/27) ----------------------------
729
758
  * Same contracts as the Ruby FFI::Function versions without the
730
759
  * per-call closure: traverse is post-order with abort-at-self
@@ -915,6 +944,72 @@ static VALUE nf_fast_pi_data(VALUE self, VALUE addr)
915
944
  return s ? rb_utf8_str_new_cstr(s) : Qnil;
916
945
  }
917
946
 
947
+ /* Shared construction for address-walk faces (TODO.perf/30):
948
+ * cache lookup + kind-dispatched binding wrapper. */
949
+ static VALUE wrap_addr_node(VALUE document, void *ptr)
950
+ {
951
+ VALUE cache, key, node;
952
+ int kind;
953
+
954
+ kind = f_node_type(ptr);
955
+ cache = binding_cache_of(document);
956
+ key = ULL2NUM((uint64_t)(uintptr_t)ptr);
957
+ node = rb_hash_aref(cache, key);
958
+ if (NIL_P(node)) {
959
+ node = rb_obj_alloc(binding_klass_for(kind));
960
+ rb_iv_set(node, "@c_address", key);
961
+ rb_iv_set(node, "@document", document);
962
+ rb_iv_set(node, "@parent", Qnil);
963
+ if (rb_obj_class(document) == c_iteration_scope) {
964
+ rb_iv_set(node, "@structure_memoizable", Qfalse);
965
+ rb_iv_set(node, "@native_fast", Qfalse);
966
+ rb_iv_set(node, "@pub_document", Qnil);
967
+ } else {
968
+ rb_iv_set(node, "@structure_memoizable", Qtrue);
969
+ rb_iv_set(node, "@native_fast", Qtrue);
970
+ rb_iv_set(node, "@pub_document", document);
971
+ }
972
+ rb_iv_set(node, "@addr_reads_fast", Qtrue);
973
+ rb_iv_set(node, "@node_type", INT2FIX(kind));
974
+ rb_hash_aset(cache, key, node);
975
+ }
976
+ return node;
977
+ }
978
+
979
+ /* First element child in one C walk (text-heavy parents paid two
980
+ * FFI calls per skipped sibling). */
981
+ static VALUE nf_first_element_child(VALUE self, VALUE document,
982
+ VALUE addr)
983
+ {
984
+ void *p;
985
+
986
+ (void)self;
987
+ resolve_binding_classes();
988
+ for (p = f_first_child((void *)(uintptr_t)NUM2ULL(addr));
989
+ p != NULL; p = f_next_sibling(p)) {
990
+ if (f_node_type(p) == NT_ELEMENT)
991
+ return wrap_addr_node(document, p);
992
+ }
993
+ return Qnil;
994
+ }
995
+
996
+ /* Last element child in one C walk (the batch fetch materialized
997
+ * every child to keep one). */
998
+ static VALUE nf_last_element_child(VALUE self, VALUE document,
999
+ VALUE addr)
1000
+ {
1001
+ void *p, *last = NULL;
1002
+
1003
+ (void)self;
1004
+ resolve_binding_classes();
1005
+ for (p = f_first_child((void *)(uintptr_t)NUM2ULL(addr));
1006
+ p != NULL; p = f_next_sibling(p)) {
1007
+ if (f_node_type(p) == NT_ELEMENT)
1008
+ last = p;
1009
+ }
1010
+ return last ? wrap_addr_node(document, last) : Qnil;
1011
+ }
1012
+
918
1013
  /* Owned string: copy + free through the engine's seam (the
919
1014
  * read_owned_string protocol). */
920
1015
  static VALUE nf_fast_path(VALUE self, VALUE addr)
@@ -1737,6 +1832,48 @@ static const rb_data_type_t dh_type = {
1737
1832
  static VALUE c_doc_handle;
1738
1833
  static ID id_iv_doc_handle, id_freed_new, id_alive;
1739
1834
 
1835
+ /* Node#dup in one dispatch (TODO.perf/30): engine create +
1836
+ * lifetime handle + document wrapper + element_copy + the copy
1837
+ * wrapped-and-rooted (memo-seeded). The namespace lift stays a
1838
+ * Ruby decision (skip_adoption_lift? — the copy_of seam's
1839
+ * semantics, #696/#721/#812). */
1840
+ static VALUE nf_copy_binding_element(VALUE self, VALUE document,
1841
+ VALUE addr)
1842
+ {
1843
+ void *doc, *copy;
1844
+ VALUE new_doc, doc_addr, handle, freed, root;
1845
+ struct doc_handle *h;
1846
+
1847
+ (void)self;
1848
+ resolve_binding_classes();
1849
+ doc = f_doc_create();
1850
+ if (!doc)
1851
+ return Qnil;
1852
+ doc_addr = ULL2NUM((uint64_t)(uintptr_t)doc);
1853
+ new_doc = rb_obj_alloc(c_b_document);
1854
+ rb_iv_set(new_doc, "@c_ptr",
1855
+ rb_funcall(c_ffi_pointer, id_ptr_new, 1, doc_addr));
1856
+ rb_iv_set(new_doc, "@c_address", doc_addr);
1857
+ freed = rb_funcall(c_b_freed, id_freed_new, 1, ID2SYM(id_alive));
1858
+ rb_iv_set(new_doc, "@freed", freed);
1859
+ rb_iv_set(new_doc, "@readonly", Qfalse);
1860
+ rb_iv_set(new_doc, "@version", INT2FIX(0));
1861
+ handle = TypedData_Make_Struct(c_doc_handle, struct doc_handle,
1862
+ &dh_type, h);
1863
+ h->doc = doc;
1864
+ rb_ivar_set(new_doc, id_iv_doc_handle, handle);
1865
+
1866
+ copy = f_elem_copy((void *)(uintptr_t)NUM2ULL(addr), doc);
1867
+ if (!copy) {
1868
+ h->doc = NULL; /* the handle releases the empty doc */
1869
+ return Qnil;
1870
+ }
1871
+ root = wrap_addr_node(new_doc, copy);
1872
+ rb_ivar_set(new_doc, rb_intern("@root"), root);
1873
+ rb_ivar_set(new_doc, rb_intern("@root_version"), INT2FIX(0));
1874
+ return new_doc;
1875
+ }
1876
+
1740
1877
  static VALUE nf_doc_handle_attach(VALUE self, VALUE document)
1741
1878
  {
1742
1879
  struct doc_handle *h;
@@ -1952,6 +2089,14 @@ void Init_native(void)
1952
2089
  nf_fast_pi_data, 1);
1953
2090
  rb_define_module_function(m_native, "fast_path",
1954
2091
  nf_fast_path, 1);
2092
+ rb_define_module_function(m_native, "first_element_child",
2093
+ nf_first_element_child, 2);
2094
+ rb_define_module_function(m_native, "last_element_child",
2095
+ nf_last_element_child, 2);
2096
+ rb_define_module_function(m_native, "copy_binding_element",
2097
+ nf_copy_binding_element, 2);
2098
+ rb_define_module_function(m_native, "set_binding_root",
2099
+ nf_set_binding_root, 2);
1955
2100
  rb_define_module_function(m_native, "fast_inner_xml",
1956
2101
  nf_fast_inner_xml, 1);
1957
2102
  rb_define_module_function(m_native, "doc_handle_attach",
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Leptris
4
- VERSION = "1.9.174.6"
4
+ VERSION = "1.9.174.8"
5
5
  end
@@ -283,14 +283,29 @@ class Leptris::XML::Document
283
283
  # document's pool until #free).
284
284
  def root=(element)
285
285
  raise Leptris::XML::UseAfterFreeError if @freed.state == :freed
286
+ # Same mutation gate as every other writer — the FFI path
287
+ # historically missed it (TODO.perf/33: the C face exposed
288
+ # the divergence; readonly is one-way, so one ivar read).
289
+ if @readonly
290
+ raise Leptris::XML::ReadOnlyError,
291
+ "document is readonly — root= attempted"
292
+ end
286
293
  # A document root has no in-scope declarations of its own —
287
294
  # lift everything the element's source scope carried (#178).
288
295
  unless Leptris::XML::Element.skip_adoption_lift?(element)
289
296
  Leptris::XML::Element.lift_namespaces_for_adoption(element, {})
290
297
  end
291
- Leptris::XML::FFI.check_status(
292
- Leptris::XML::FFI.leptris_document_set_root(c_ptr, element.c_ptr))
293
- @version += 1
298
+ # TODO.perf/33: gates + bump + engine set_root in one C
299
+ # dispatch (the FFI call plus the c_ptr materialization fed
300
+ # ~5% of a fresh-doc build).
301
+ if defined?(Leptris::XML::NATIVE_FAST)
302
+ Leptris::XML::FFI.check_status(
303
+ Leptris::XML::Native.set_binding_root(self, element.c_address))
304
+ else
305
+ Leptris::XML::FFI.check_status(
306
+ Leptris::XML::FFI.leptris_document_set_root(c_ptr, element.c_ptr))
307
+ @version += 1
308
+ end
294
309
  # Seed the root memo through wrap: a cross-document element
295
310
  # must enter THIS document's identity cache with @document
296
311
  # pointing here, not ride its source-document wrapper.
@@ -497,9 +497,21 @@ class Leptris::XML::Element < Leptris::XML::Node
497
497
 
498
498
  # Deep copy in a NEW document via Document.copy_of (the single
499
499
  # copy seam — every child kind and namespace survives,
500
- # #696/#721/#812).
500
+ # #696/#721/#812). TODO.perf/30: one C dispatch when the ext
501
+ # is loaded — the namespace-lift decision stays here.
501
502
  def dup
502
503
  ensure_alive!
504
+ if @addr_reads_fast
505
+ doc = Leptris::XML::Native.copy_binding_element(
506
+ @document, @c_address)
507
+ unless doc.nil?
508
+ copied = doc.root
509
+ unless Leptris::XML::Element.skip_adoption_lift?(copied)
510
+ Leptris::XML::Element.lift_namespaces_for_adoption(copied, {})
511
+ end
512
+ return copied
513
+ end
514
+ end
503
515
  Leptris::XML::Document.copy_of(self)
504
516
  end
505
517
  alias_method :clone, :dup
@@ -8,6 +8,12 @@ class Leptris::XML::Node
8
8
  @c_ptr ||= ::FFI::Pointer.new(@c_address)
9
9
  end
10
10
 
11
+ # The Integer twin of #c_ptr — reading it never materializes a
12
+ # Pointer (the native faces take addresses).
13
+ def c_address
14
+ @c_address
15
+ end
16
+
11
17
  # Iterparse-yielded elements are owned by an IterationScope (the
12
18
  # internal lifetime/memoization authority) — the public #document
13
19
  # answers nil for them, per the documented contract.
@@ -390,6 +396,17 @@ class Leptris::XML::Node
390
396
  def first_element_child
391
397
  return @first_element_child if memo_hit?(@first_element_child_version)
392
398
  ensure_alive!
399
+ # TODO.perf/30: one C walk (the FFI scan paid two calls per
400
+ # skipped non-element sibling).
401
+ if @addr_reads_fast
402
+ result = Leptris::XML::Native.first_element_child(
403
+ @document, @c_address)
404
+ if @document
405
+ @first_element_child = result
406
+ @first_element_child_version = @document.version
407
+ end
408
+ return result
409
+ end
393
410
  # Raw pointer scan: non-element siblings are typed with one C
394
411
  # call each — never wrapped, never cached — and the found
395
412
  # element carries the ELEMENT hint into the wrap.
@@ -413,8 +430,12 @@ class Leptris::XML::Node
413
430
  end
414
431
 
415
432
  def last_element_child
416
- # Element receivers: the element-only batch fetches pointers
417
- # without wrapping any text child; only the last is wrapped.
433
+ # TODO.perf/30: one C walk — the batch fetch materialized
434
+ # every child pointer to keep one.
435
+ if @addr_reads_fast
436
+ return Leptris::XML::Native.last_element_child(
437
+ @document, @c_address)
438
+ end
418
439
  if is_a?(Leptris::XML::Element)
419
440
  kids = Leptris::XML::FFI.fetch_element_children(c_ptr)
420
441
  return nil if kids.empty?
@@ -600,6 +621,23 @@ class Leptris::XML::Node
600
621
  # non-element kinds rebuild by value — #161).
601
622
  def dup
602
623
  ensure_alive!
624
+ # TODO.perf/30: elements copy in one C dispatch (engine
625
+ # create + handle + element_copy + rooted wrap). The
626
+ # namespace lift stays a Ruby decision — the copy_of seam's
627
+ # semantics (#696/#721/#812) run below when needed.
628
+ if element? && @addr_reads_fast
629
+ doc = Leptris::XML::Native.copy_binding_element(
630
+ @document, @c_address)
631
+ unless doc.nil?
632
+ # copy_of's contract returns the copied ROOT (the last
633
+ # expression of the root= assignment), not the document.
634
+ copied = doc.root
635
+ unless Leptris::XML::Element.skip_adoption_lift?(copied)
636
+ Leptris::XML::Element.lift_namespaces_for_adoption(copied, {})
637
+ end
638
+ return copied
639
+ end
640
+ end
603
641
  Leptris::XML::Document.copy_of(self)
604
642
  end
605
643
  alias_method :clone, :dup
@@ -69,7 +69,16 @@ module Leptris::XML::Searchable
69
69
  doc_ptr, context_ptr, expr,
70
70
  Leptris::XML::Searchable.xpath_version_code(version), nil)
71
71
  elsif ns && !ns.empty?
72
- xpath_eval_with_namespaces(doc_ptr, context_ptr, expr, ns)
72
+ # TODO.perf/29: the compiled handle is ns-independent —
73
+ # ride the cache and the cached ns set instead of the
74
+ # engine's string parse per call.
75
+ if (compiled = Leptris::XML::Searchable.compiled_expression(expr))
76
+ Leptris::XML::FFI.with_ns_set(ns) do |set|
77
+ compiled.eval_ns_ptrs(doc_ptr, context_ptr, set)
78
+ end
79
+ else
80
+ xpath_eval_with_namespaces(doc_ptr, context_ptr, expr, ns)
81
+ end
73
82
  elsif (compiled = Leptris::XML::Searchable.compiled_expression(expr))
74
83
  compiled.eval_ptrs(doc_ptr, context_ptr)
75
84
  else
@@ -105,7 +114,16 @@ module Leptris::XML::Searchable
105
114
  doc_ptr, context_ptr, expr,
106
115
  Leptris::XML::Searchable.xpath_version_code(version), nil)
107
116
  elsif ns && !ns.empty?
108
- xpath_eval_with_namespaces(doc_ptr, context_ptr, expr, ns)
117
+ # TODO.perf/29: the compiled handle is ns-independent —
118
+ # ride the cache and the cached ns set instead of the
119
+ # engine's string parse per call.
120
+ if (compiled = Leptris::XML::Searchable.compiled_expression(expr))
121
+ Leptris::XML::FFI.with_ns_set(ns) do |set|
122
+ compiled.eval_ns_ptrs(doc_ptr, context_ptr, set)
123
+ end
124
+ else
125
+ xpath_eval_with_namespaces(doc_ptr, context_ptr, expr, ns)
126
+ end
109
127
  elsif (compiled = Leptris::XML::Searchable.compiled_expression(expr))
110
128
  compiled.eval_ptrs(doc_ptr, context_ptr)
111
129
  else
@@ -40,6 +40,14 @@ class Leptris::XML::XPath
40
40
  @handle, doc_ptr, context_ptr)
41
41
  end
42
42
 
43
+ # Namespace-bound evaluation against the compiled handle
44
+ # (TODO.perf/29): the handle is ns-independent — compile once,
45
+ # bind per call through the cached ns set.
46
+ def eval_ns_ptrs(doc_ptr, context_ptr, ns_set_ptr)
47
+ Leptris::XML::FFI.leptris_xpath_compiled_eval_ns(
48
+ @handle, doc_ptr, context_ptr, ns_set_ptr)
49
+ end
50
+
43
51
  def initialize(expression, handle, version_code = nil)
44
52
  @expression = expression
45
53
  @handle = handle
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: leptris
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.174.6
4
+ version: 1.9.174.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-15 00:00:00.000000000 Z
11
+ date: 2026-09-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ffi
@@ -97,6 +97,11 @@ files:
97
97
  - TODO.perf/26-post-mutation-memo-seeding.md
98
98
  - TODO.perf/27-c-yield-traversal.md
99
99
  - TODO.perf/28-address-fills.md
100
+ - TODO.perf/29-ns-xpath-compiled.md
101
+ - TODO.perf/30-copy-and-element-child-faces.md
102
+ - TODO.perf/31-battery-rounds-4-8.md
103
+ - TODO.perf/32-clean-host-floor-table.md
104
+ - TODO.perf/33-cbound-root-set.md
100
105
  - TODO.restructure/01-constraint-compliance-audit.md
101
106
  - TODO.restructure/02-deep-copy-seam.md
102
107
  - TODO.restructure/03-evaluation-context-seam.md