Skip to content

Implement symbol and compound capture patterns in the pure sink - #137

Open
MesTTo wants to merge 2 commits into
trueagi-io:mainfrom
MesTTo:pr/mork-pure-compound-capture
Open

Implement symbol and compound capture patterns in the pure sink#137
MesTTo wants to merge 2 commits into
trueagi-io:mainfrom
MesTTo:pr/mork-pure-compound-capture

Conversation

@MesTTo

@MesTTo MesTTo commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Fixes #136.

A pure sink whose capture pattern led with a symbol or a compound hit the todo arms in PureSink::finalize and panicked (unrecognized sink at the issue's commit, not yet implemented at current main). Both classes now run one rejection-pattern path: evaluate the call, unify the result against the pattern, and emit the template under the resulting bindings. A result that does not unify emits nothing.

The mechanism is the engine's own matching rather than a bespoke walker. The stored (template pattern call) triple is wrapped in an Arity(3) so ExprEnv::args gives the three subexpressions one shared variable namespace, the pattern unifies against the evaluated result with unify, and the template is instantiated with apply_e under the same cycle check dump_sexpr uses. Data-side variables, repeated variables, and occurs rejection therefore behave exactly as in a rewrite over (mytag <pat>), which is the workaround this replaces:

(exec 0 (,) (O (pure (R $x $y) ($x $y) (tuple 1 2))))

now yields (R 1 2), matching the two-exec workaround byte for byte. A ground symbol pattern is the degenerate case of the same path and acts as a guard: (pure (ok $i) 321 (reverse_symbol $i)) over (A 123) and (A 456) emits only (ok 123). Unbound template variables stay schematic, and each body match unifies its own instantiated pattern, so ground positions reject per firing.

Two limitations remain. A fully constant template cannot emit below its own request root, because the request then covers the whole sink expression; the variable arms share that limitation, and this path traces and skips instead of panicking. And #135 (quotation treating every variable as new) is untouched, since that sits in evaluation upstream of the sink.

Validation:

  • mork test passes in full, including four new cases: compound destructuring with nesting, ground and repeated-variable rejection with an accepting control, the symbol guard, and per-match multiplicity.
  • Bench counters are unchanged against a stock build of the same base: process_calculus unifications 201401, instructions 427949817; counter_machine steps 1813 size 2753. The new path is unreachable unless a stored pattern leads with a symbol or arity byte, which previously panicked.
  • git diff --check clean; jscpd reports no duplication beyond the file's existing test scaffold template.

A pure sink whose capture pattern led with a symbol or a compound hit the
todo arms in PureSink::finalize and panicked (sinks.rs:1145, reported in
issue 136). Both classes now run one rejection-pattern path: evaluate the
call, unify the result against the pattern, and emit the template under
the resulting bindings; a result that does not unify emits nothing.
Wrapping the stored (template pattern call) triple in an Arity(3) gives
the three subexpressions one shared variable namespace, so the pattern's
VarRefs resolve to the template's introductions, and the engine's own
unify and apply_e do the matching: data-side variables, repeated
variables, and occurs rejection behave exactly as in a rewrite over
(mytag <pat>).

(exec 0 (,) (O (pure (R $x $y) ($x $y) (tuple 1 2)))) now yields (R 1 2),
matching the two-exec workaround byte for byte. Unbound template
variables stay schematic, and each body match unifies its own
instantiated pattern, so ground positions reject per firing. A fully
constant template still cannot emit below its own request root; the
variable arms share that limitation.

Four test cases cover destructuring and nesting, ground and
repeated-variable rejection with an accepting control, the symbol guard,
and per-match multiplicity. The full mork test suite passes and bench
counters are unchanged (process_calculus unifications 201401,
instructions 427949817; counter_machine steps 1813 size 2753).
@Adam-Vandervorst

Copy link
Copy Markdown
Collaborator

Hi! The reason for the specialization of the common case is performance. Please benchmark the pure sink specifically.

@MesTTo

MesTTo commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

benchmarked the pure sink on the PR tip vs base (45bdb9a), release, target-cpu=native, deterministic counters plus min-of-5 interleaved wall.

the specialized arms aren't touched by the diff. the new path hangs off the pattern's leading byte class (symbol/arity), which previously hit the todo!(). measured: every pure-using program in kernel/resources (grounding, string_convert, ip_sudoku, decision_tree_learning_without_min_sink) is counter-identical base vs PR, and a 100k-row bare-var synthetic is counter-identical too (400,006 transitions both; wall 132 vs 140ms, winners split across runs, load noise). dtl-with-min-sink doesn't quiesce on either binary, excluded both sides.

the new path vs the two-exec workaround it replaces (capture into a var, rematch with a second exec), same input, verified-identical result sets, N=100k: 200k vs 300k unifications, 100k vs 200k writes, 500,006 vs 800,009 transitions, 149 vs 213ms. the workaround also leaves the N tag facts in the space, not charged here. next to a comparable bare-var workload the compound arm costs ~1 extra transition per row (500,006 vs 400,006), so the bare-var specialization keeps earning its place, and the generic arm only takes the shapes that used to panic. reject path (ground-symbol guard) is cheaper than accept, 300,006 transitions at 100k, since rejection skips template application.

@adamv-symbolica

Copy link
Copy Markdown

I don't get why this is specialized to arity 3.

@MesTTo

MesTTo commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

i am not specializing on the pattern's arity there. the 3 is pure's own operand count, and the
sink already hardcodes the same fact a few lines above where i touch it.

pure is a fixed 3-operand form, (pure <template> <pattern> <call>). it gets selected by matching
Arity(4) then SymbolSize(4) then "pure" in sinks.rs, and request() strips exactly those 6
bytes with [6..]. so what i find stored under the request root is the three subexpressions
concatenated with no wrapper around them: template | pattern | call.

that concatenation is not a well-formed expression on its own, so to read it back i push one
Arity(3) byte in front of it. that makes it one expression again and lets .args() split it into
exactly the three parts it was built from. the 3 i write is the same 3 the Arity(4) head
implies, just without the symbol.

i re-wrap rather than parse the three separately because of variable scope. parsed separately, each
subexpression starts its own de Bruijn numbering, and a $x introduced in the template would not be
the $x the pattern refers to. under one Arity(3) they share a namespace and the pattern's VarRefs
resolve to the template's introductions, which is what makes the capture work at all.

the pattern's own arity is unrestricted, which is the part i think the question is really about. the
second test i added here is (pure (S $a $b) (($a) ($b)) (tuple (tuple 3) (tuple 4))), a compound
pattern with nested compounds, and it goes through the same arm. nothing in the new code branches on
how wide the pattern is.

@Adam-Vandervorst

Copy link
Copy Markdown
Collaborator

Right right, I've performed this trick elsewhere, too. Though I believe we should abstract this out for the sinks of this shape, and not create the intermediate 🤔

The pure sink copied each stored (template pattern call) triple under a
synthetic Arity(3) byte purely so args had an arity tag to read k off.
Lift the per-subterm loop out of args into subterms(k), which takes the
count as an argument and starts at the first subterm, and args becomes
that after stepping over its arity byte.

The sink then splits the run in place. The de Bruijn threading is
unchanged, so the three still share one namespace and the pattern's
VarRefs still resolve to the template's introductions.

mork test is byte-identical, bench counters are unchanged
(process_calculus unifications 201401, instructions 427949817), and
20k compound-capture rows retire 711,383 fewer instructions; the saving
per row tracks triple length (35.6 short, 46.1 long), which is the copy.
@MesTTo

MesTTo commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

done both, pushed as one commit.

the intermediate is gone. the copy only existed so args had an Arity(k) byte to read k off, so
i lifted its per-subterm loop into subterms(k, dest), which takes the count as an argument and
starts at the first subterm instead of a parent tag. args is now that after stepping over its own
arity byte:

pub fn args(&self, dest: &mut Vec<Self>) {
    match byte_item(*self.subsexpr().ptr) {
        Tag::NewVar | Tag::VarRef(_) | Tag::SymbolSize(_) => { }
        Tag::Arity(k) => { self.offset(1).subterms(k, dest) }
    }
}

and the sink splits the stored run in place:

ExprEnv::new(0, Expr { ptr: full.as_ptr().cast_mut() }).subterms(3, &mut wargs);

the de Bruijn threading is untouched, so the three still share one namespace and the pattern's
VarRefs still resolve to the template's introductions. that was the only thing the wrap was buying.

verification: mork test output is byte-identical, and the bench counters are unchanged,
process_calculus still unifications 201401, instructions 427949817. on 20k compound-capture rows
the deterministic counters match exactly (unifications 200000, writes 100000, transitions 300006) and
it retires 711,383 fewer instructions. i checked that number is the copy rather than noise by making
the triple longer: the per-row saving goes 35.6 to 46.1 as the triple grows, which is what a removed
memcpy does and noise does not. callgrind rather than wall clock, this box is loaded.

one thing i should correct from my own framing, since you said "for the sinks of this shape". i went
looking for the other instances and there are none. nine sinks strip [2 + name_len ..] the same
way, but none of them decompose the operand run into typed subterms, they walk the path with the
zipper directly. CountSink is representative. so subterms is the right shape for the family and
the next sink that needs typed operands should use it, but it is not deduplicating anything today
and i do not want to claim it is. it lives on ExprEnv rather than in the sinks so it is there when
that happens.

it also composes with #138 without a conflict beyond the textual overlap: that PR replaces the
traverseh! inside this loop with the pending counter, and the extraction does not care which scan
is in the body.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pure fails to capture output pattern outside of a mere variable

3 participants