8000 fix(engine): probe joins on a bound value through AVET (#721) by adityamukho · Pull Request #722 · project-minigraf/minigraf · GitHub
[go: up one dir, main page]

Skip to content

fix(engine): probe joins on a bound value through AVET (#721) - #722

Merged
adityamukho merged 7 commits into
v3from
fix/721-value-probes
Oct 23, 2026
Merged

adityamukho merged 7 commits into
v3from
fix/721-value-probes

Conversation

@adityamukho
@adityamukho adityamukho commented Oct 23, 2026 •
Copy link
Copy Markdown
Collaborator

Fixes the recursion regression in #721 (issue closed by hand after merge; closing keywords do not fire on v3). Plan: docs/superpowers/plans/2026-10-23-721-value-probes.md.

Problem

Since #700, the delta run of (boss :leaf ?b) (after the magic-set rewrite: (boss ?y ?z) then [?x :manager ?y]) joins :manager on its value. That join was a HashJoin over every :manager fact, rebuilt every round: recursive_ancestors 1.8 s at 1M facts and 35 s at 10M instead of 2–3 ms.

Change

  • JoinKind::ProbeValue { key, build_inner }: a join whose inner pattern has a constant attribute, a non-constant entity and a bound value (and reads no relation) probes per outer row: the AVET ranges of the values that join-equal the row's value, and the attribute's uncheckpointed facts with those values. The join checks every key, so answers are the hash join's.
  • The values that join-equal a cell (scan::candidates): a keyword and a ref to the entity it names, either way; 0.0 and -0.0; nothing for NaN.
  • Read once per query (IdSpace::probes): the attribute's pending facts (by encode_value) and the committed triples they shadow, so a rule run in every round, or a body run per row, reads them once.
  • Entity cells. A keyword's entity is a UUID v5 of its name; no index leads back from the entity. The query records the keywords it makes entities from: rule heads, and the keyword constants its rule calls name (the magic seeds). An entity with no recorded keyword, on an attribute with keyword values (committed or pending), turns the join into the hash join the planner chose before, built on the side it named, for that row and the rest.
  • Criterion recursion/ancestors joins the nightly recursion group.

Two designs were measured and dropped (plan, deviations 5–8): per-scan reads (reread every round) and a reverse keyword map read from the attribute's keyword range (join_3_scan 15.6 s and 681 MiB at 10M).

Measurements (bench machine, v3 dfa2041 vs this branch, parallel on separate sockets, two rounds)

#394 envelope, p50:

Shape 100K 1M 10M
recursive_ancestors 100 ms → 1.1 ms 1.80 s → 1.4 ms 35.0 s → 1.9 ms (610 → 4 MiB)
join_3_selective 10.4 ms → 0.21 ms 144 ms → 0.20 ms 2.36 s → 0.20 ms
every other shape unchanged unchanged unchanged

The point shapes (point_entity, attribute_value, join_2) are bimodal per process (about 18 or 54 µs) on both builds; six fresh processes per build gave the same distributions. One recursive 10M run hit the 60 s cap (both builds take 56–59 s).

Criterion recursion (in memory): ancestors/w10_d4 97.7 → 15.1 ms, chain/depth_100 41.4 → 20.8 ms, chain/depth_10 0.68 → 0.53 ms, fanout/w10_d3 22.7 → 20.8 ms. In memory every fact is pending, and the first probe reads the attribute's pending facts once per query (12 ms of the 15 ms for 11,110 facts); before #700 ancestors was 0.27 ms. A pending value index would remove that read; #545 (decision 3) left it out. Tracked in #723.

Release binary: 1,154 KiB (gate 1,200).

Tests

  • rule_delta_probes_a_bound_value: the ancestors delta run probes by value and hashes no stored attribute; answers in memory and on a half-checkpointed file.
  • value_probe_reads_only_the_probed_keys: operator counters, through a pending retraction of a committed fact.
  • value_probe_matches_as_joins_do: keyword and ref from either side, an entity cell with no recorded keyword (the fallback), 0.0/-0.0, long strings, :as-of, :valid-at; in memory, checkpointed, half pending.
  • value_probe_falls_back_mid_batch: a fallback after probes by keyword, mid-batch, over several batches: every row joins once.
  • planner_chooses_the_join updated (a value join is now a probe).
  • Frozen answers (tests/answers/), plan independence and model-based tests unchanged. Bench machine: 1,701 passed / 12 ignored; with test-hooks 1,717 / 13.

Codex review, two rounds: (1) probe cache charge kept after IdSpace::release — not a defect, Held releases on drop; (2) fallback loses rows — not a defect (rows before the miss were emitted by their probes; the table or the stream covers the rest and later batches), now covered by value_probe_falls_back_mid_batch.

🤖 Generated with Claude Code

https://claude.ai/code/session_0169pNQsLcCEn48te9X9WuZW

adityamukho and others added 6 commits October 23, 2026 20:05
A join whose inner pattern shares only its value with the rows so far,
under a constant attribute, was a HashJoin over every fact of the
attribute. In a rule's delta run it was rebuilt every round: since #700
the ancestors shape (boss :leaf ?b) took 1.8 s at 1M facts instead of
2 ms. It is now an IndexNestedLoopJoin on the value: per outer row, the
AVET ranges of the values that join-equal it (a keyword and the ref to
the entity it names, either way; 0.0 and -0.0), and the attribute's
pending facts with its join key, read once per scan with the shadow set.
The join checks every key, so answers are the hash join's. A pattern
that reads a relation, or has a constant entity, keeps its hash join.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0169pNQsLcCEn48te9X9WuZW
…ty's keyword is unknown (#721)

The pending facts and shadow set of a value probe are read once per
query, not per scan, so a rule run in every round reads them once.

An entity cell must also find the keyword that names it, and a keyword's
entity is a hash of its name: no index leads back. The query records the
keywords it makes entities from (rule heads, and the keyword constants
its rule calls name, the magic seeds); a probe by an entity without one,
on an attribute with committed keyword values, turns the join into the
hash join the planner chose before, on the side it named.

recursive_ancestors (bench machine, p50): 1.1 ms at 100K, 1.4 ms at 1M,
1.8 ms at 10M (v3: 101 ms, 1.8 s, 35 s). join_3_selective 145 ms -> 0.2 ms
at 1M; join_3_scan unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0169pNQsLcCEn48te9X9WuZW
The nightly recursion group gains the ancestors shape, whose rounds join
on a value, next to fanout. CLAUDE.md and TEST_COVERAGE describe value
probes; the plan records what measuring changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0169pNQsLcCEn48te9X9WuZW
…e list (#721)

The attribute's pending facts are keyed by encode_value, so building the
per-query map hashes no keyword names; each probe looks up the values that
join-equal its cell, the same list that gives the committed AVET ranges.
An entity cell with no recorded keyword falls back to the hash join when
the attribute has pending keyword values too. CHANGELOG entry with the
envelope and Criterion numbers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0169pNQsLcCEn48te9X9WuZW
@codecov
codecov Bot commented Oct 23, 2026
Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.49838% with 17 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/query/engine/scan.rs 94.81% 5 Missing and 6 partials ⚠️
src/query/engine/recursive.rs 66.66% 2 Missing and 1 partial ⚠️
src/query/engine/join.rs 95.45% 2 Missing ⚠️
src/query/engine/mod.rs 87.50% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0169pNQsLcCEn48te9X9WuZW
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.

1 participant

0