⏺ Stage A is built and green. Summary: #20

Merged
navicore merged 2 commits from fact-table-stage-A into main 2026-06-18 17:54:34 +00:00
Owner

What landed

Runtime (crates/runtime/src/builtins/facts.rs, + mod.rs): plg_rt_fact_first
/ plg_rt_fact_next / fact_scan — a generic full-scan lookup that mirrors
between/3's nondeterministic shape: a control frame holds the snapshot args

  • continuation + cursor, each row is unified against the args, the choice
    point captures pre-binding marks so backtracking undoes the delivered row.
    The helpers find/bind/push-CP and return; delivery to the continuation is a
    musttail in the generated IR (see below).

Codegen (crates/compiler/src/codegen/facts.rs, + program.rs routing &
decls, mod.rs): a qualifying predicate emits one .rodata table of immediate
words (row-major, program order) + a tiny entry fn and _ftr retry fn that
call the runtime lookup and musttail the continuation. Registered under the
normal pred_symbol, so call/findall/query dispatch reach it like any
predicate — no registry change.

The key design point: delivery is a musttail in the generated entry/retry
functions, not a Rust call to k. A Rust helper calling k would leave its
frame on the C stack per recursion level — overflowing on recursion through
a fact predicate. Returning first keeps the constant-stack guarantee.
(Recorded in memory so Stages B/C preserve it.)

Checkpoints

  1. Equivalence — just diff-test is byte-identical to the v1 oracle (its
    corpus runs findall/call/recursive path over fact predicates); the
    family.pl suite passes unchanged.
  2. Compile time structurally — O(1) data emission; 5000 facts → one
    table + two functions, compiles in ~125ms.
  3. Footprint — binary_size gate green; hello-world unchanged (no fact
    predicates).
  4. Mixed + re-entry — new fact_table.rs (9 tests): program-order
    backtracking, ground true/false, int columns both directions, --limit,
    findall/call re-entry, recursive rule over the edge table, a
    compound-column fact staying per-clause in the same binary, undefined →
    existence_error. Plus deep_recursion_runs_in_constant_c_stack already
    recurses 2000-deep through a fact table under a 512KB stack.
  5. unsafe seam — deferred, as designed.

Tests: 3 golden-IR tests repointed to non-qualifying inputs (preserving
their per-clause/indexing coverage) + 1 new fact-table golden test, per
your "repoint + add" choice. Full workspace green, clippy + fmt clean.

Two honest notes

  • Column types narrowed to atom + i61-integer (pure immediates) rather than
    the card's "atom/int/float." Float and big-int are boxed scalars
    (non-immediate), so they need the same column-encoding work as compounds —
    I folded them into the deferred Stage C rather than half-encode floats now.
    Verified the fallback: big(i64::MAX) and coords(origin, point(0,0)) both
    stay per-clause and work.
  • The build.rs mtime archive-staleness bit again mid-build (stale embedded
    runtime → undefined plg_rt_fact_*); a rebuild refreshed it. Known stable
    limitation, not a regression.

Deferred to follow-ups: first-arg index + binary search (Stage B),
compound/float/big columns (Stage C), and the dedicated 100k compile-time +
fact-table-vs-per-clause size benchmarks the design lists as confirming
evidence.

What landed Runtime (crates/runtime/src/builtins/facts.rs, + mod.rs): plg_rt_fact_first / plg_rt_fact_next / fact_scan — a generic full-scan lookup that mirrors between/3's nondeterministic shape: a control frame holds the snapshot args + continuation + cursor, each row is unified against the args, the choice point captures pre-binding marks so backtracking undoes the delivered row. The helpers find/bind/push-CP and return; delivery to the continuation is a musttail in the generated IR (see below). Codegen (crates/compiler/src/codegen/facts.rs, + program.rs routing & decls, mod.rs): a qualifying predicate emits one .rodata table of immediate words (row-major, program order) + a tiny entry fn and _ftr retry fn that call the runtime lookup and musttail the continuation. Registered under the normal pred_symbol, so call/findall/query dispatch reach it like any predicate — no registry change. The key design point: delivery is a musttail in the generated entry/retry functions, not a Rust call to k. A Rust helper calling k would leave its frame on the C stack per recursion level — overflowing on recursion through a fact predicate. Returning first keeps the constant-stack guarantee. (Recorded in memory so Stages B/C preserve it.) Checkpoints 1. Equivalence ✅ — just diff-test is byte-identical to the v1 oracle (its corpus runs findall/call/recursive path over fact predicates); the family.pl suite passes unchanged. 2. Compile time ✅ structurally — O(1) data emission; 5000 facts → one table + two functions, compiles in ~125ms. 3. Footprint ✅ — binary_size gate green; hello-world unchanged (no fact predicates). 4. Mixed + re-entry ✅ — new fact_table.rs (9 tests): program-order backtracking, ground true/false, int columns both directions, --limit, findall/call re-entry, recursive rule over the edge table, a compound-column fact staying per-clause in the same binary, undefined → existence_error. Plus deep_recursion_runs_in_constant_c_stack already recurses 2000-deep through a fact table under a 512KB stack. 5. unsafe seam — deferred, as designed. Tests: 3 golden-IR tests repointed to non-qualifying inputs (preserving their per-clause/indexing coverage) + 1 new fact-table golden test, per your "repoint + add" choice. Full workspace green, clippy + fmt clean. Two honest notes - Column types narrowed to atom + i61-integer (pure immediates) rather than the card's "atom/int/float." Float and big-int are boxed scalars (non-immediate), so they need the same column-encoding work as compounds — I folded them into the deferred Stage C rather than half-encode floats now. Verified the fallback: big(i64::MAX) and coords(origin, point(0,0)) both stay per-clause and work. - The build.rs mtime archive-staleness bit again mid-build (stale embedded runtime → undefined plg_rt_fact_*); a rebuild refreshed it. Known stable limitation, not a regression. Deferred to follow-ups: first-arg index + binary search (Stage B), compound/float/big columns (Stage C), and the dedicated 100k compile-time + fact-table-vs-per-clause size benchmarks the design lists as confirming evidence.
⏺ Stage A is built and green. Summary:
All checks were successful
CI - Linux / CI - Linux x86_64 (pull_request) Successful in 1m0s
866f7f0890
What landed

  Runtime (crates/runtime/src/builtins/facts.rs, + mod.rs): plg_rt_fact_first
  / plg_rt_fact_next / fact_scan — a generic full-scan lookup that mirrors
  between/3's nondeterministic shape: a control frame holds the snapshot args
  + continuation + cursor, each row is unified against the args, the choice
  point captures pre-binding marks so backtracking undoes the delivered row.
  The helpers find/bind/push-CP and return; delivery to the continuation is a
  musttail in the generated IR (see below).

  Codegen (crates/compiler/src/codegen/facts.rs, + program.rs routing &
  decls, mod.rs): a qualifying predicate emits one .rodata table of immediate
  words (row-major, program order) + a tiny entry fn and _ftr retry fn that
  call the runtime lookup and musttail the continuation. Registered under the
  normal pred_symbol, so call/findall/query dispatch reach it like any
  predicate — no registry change.

  The key design point: delivery is a musttail in the generated entry/retry
  functions, not a Rust call to k. A Rust helper calling k would leave its
  frame on the C stack per recursion level — overflowing on recursion through
  a fact predicate. Returning first keeps the constant-stack guarantee.
  (Recorded in memory so Stages B/C preserve it.)

  Checkpoints

  1. Equivalence  — just diff-test is byte-identical to the v1 oracle (its
  corpus runs findall/call/recursive path over fact predicates); the
  family.pl suite passes unchanged.
  2. Compile time  structurally — O(1) data emission; 5000 facts → one
  table + two functions, compiles in ~125ms.
  3. Footprint  — binary_size gate green; hello-world unchanged (no fact
  predicates).
  4. Mixed + re-entry  — new fact_table.rs (9 tests): program-order
  backtracking, ground true/false, int columns both directions, --limit,
  findall/call re-entry, recursive rule over the edge table, a
  compound-column fact staying per-clause in the same binary, undefined →
  existence_error. Plus deep_recursion_runs_in_constant_c_stack already
  recurses 2000-deep through a fact table under a 512KB stack.
  5. unsafe seam — deferred, as designed.

  Tests: 3 golden-IR tests repointed to non-qualifying inputs (preserving
  their per-clause/indexing coverage) + 1 new fact-table golden test, per
  your "repoint + add" choice. Full workspace green, clippy + fmt clean.

  Two honest notes

  - Column types narrowed to atom + i61-integer (pure immediates) rather than
  the card's "atom/int/float." Float and big-int are boxed scalars
  (non-immediate), so they need the same column-encoding work as compounds —
  I folded them into the deferred Stage C rather than half-encode floats now.
  Verified the fallback: big(i64::MAX) and coords(origin, point(0,0)) both
  stay per-clause and work.
  - The build.rs mtime archive-staleness bit again mid-build (stale embedded
  runtime → undefined plg_rt_fact_*); a rebuild refreshed it. Known stable
  limitation, not a regression.

  Deferred to follow-ups: first-arg index + binary search (Stage B),
  compound/float/big columns (Stage C), and the dedicated 100k compile-time +
  fact-table-vs-per-clause size benchmarks the design lists as confirming
  evidence.
Author
Owner

Review — Fact-table compilation, Stage A

Substantial PR, and the architectural calls are right. The headline decision — delivery is a musttail in the generated entry/retry IR, NOT a Rust call to k from the runtime helper — is exactly the discipline that keeps recursion through a fact predicate constant-stack. The runtime helper finds/binds/pushes-CP and returns; the IR's deliver: block tail-calls. Without this, a recursive path/2 over an edge table would overflow the C stack proportional to recursion depth. Worth the prominent placement in the doc comments.

Other strong calls:

  • Per-predicate qualification. is_fact_predicate runs per (functor, arity); mixed programs compile with per-predicate routing. No all-or-nothing.
  • Frame-reuse across choice points. All CPs of one fact-predicate invocation point at the same frame (allocated once in plg_rt_fact_first). N solutions don't allocate N frames.
  • Pre-binding rewind-then-push-CP-then-rebind. The CP captures the pre-binding trail/heap marks, so backtracking through this row undoes only what this row did. Documented in comments.
  • No registry change. The fact-table entry is registered under the same pred_symbol, so call/findall/query dispatch reach it like any other predicate.
  • is_fact_predicate narrows columns to atom + i61-integer. Compound/float/big-int correctly fall back to per-clause (proven by compound_column_predicate_stays_per_clause). Half-encoding floats now would have been the wrong scope expansion.
  • Last-row CP elision. if i + 1 < nrows { push_cp(...) } saves an allocation when the matching row is the last.

Issues below, ordered by impact.

Real concerns

1. Stage A full-scan with no first-arg index is correct but doesn't yet test the headline claim. The design's compile-time win (O(1) data emission vs O(N) functions) is structurally argued and the structural argument is sound. But the runtime claim — that fact-table queries stay reasonable at 100k facts — leans on Stage B's binary-search index, which isn't here. A parent(tom, X) against a 100k-row table scans all 100k rows linearly per solution. Checkpoint 2 in FACT_TABLE.md asks for "a 100k-fact predicate compiles in roughly constant data-emission time; record IR size + clang time before/after." The PR has 5000 facts compiling in ~125ms structurally but no measured 100k number. Worth either landing the benchmark now (it's a one-off shell script — doesn't need to be CI) or marking checkpoint 2 explicitly as "structurally , measurement deferred" in the doc. Right now FACT_TABLE.md and the PR description both call it , which is ahead of the evidence.

2. The unsafe transmute::<usize, ContFn> is repeated at two sites. facts.rs:

// fact_scan:
let retry: ContFn = unsafe { std::mem::transmute::<usize, ContFn>(m.heap[frame + RETRY] as usize) };
// plg_rt_fact_next:
m.k_fn = unsafe { std::mem::transmute::<usize, ContFn>(m.heap[frame + KFN] as usize) };

Each one is the same invariant: "a heap cell stores a ContFn we wrote via ptrtoint in the IR." A helper:

unsafe fn read_contfn(word: u64) -> ContFn {
    unsafe { std::mem::transmute::<usize, ContFn>(word as usize) }
}

documents the invariant once and centralizes the unsafe surface. Stage B/C will add more raise sites; centralizing now is cheap.

3. The double-unify-and-rewind on a matching row is correct but wasteful. fact_scan's match path:

  1. Inner loop unifies each col → bindings on trail
  2. rewind_to(clean_t, clean_h) undoes step 1
  3. push_cp(retry, frame) captures clean trail height
  4. Inner loop re-unifies — bindings re-pushed onto trail

That's 2N unifications per matching row in the success case. For a 100k-fact predicate where every row matches (e.g. findall(X, fact(X), L)), that's 200k unification ops where 100k would suffice. A future optimization can:

  • Capture trail_mark = m.trail.len() before the first unify
  • On match, push_cp(retry, frame) records the captured mark as the restore-point
  • Skip the rewind and the second unify

Defer to Stage B alongside the index, but worth noting now while the design is fresh. The current shape is correct; that's what matters for Stage A.

Small observations

4. The asymmetry between plg_rt_fact_first and plg_rt_fact_next. first saves m.k_fn/m.k_env/m.qbarrier into the frame but does not restore them after fact_scan (it relies on the invariant that m.k_fn is untouched at the time deliver: reads it). next does restore them because the solve driver may have overwritten them between CP push and CP pop. The asymmetry is correct, but a one-line comment in first saying "no restore needed: the caller's k_fn is still in m.k_fn from the entry frame" would save a future reader the analysis I just did.

5. The i64-as-pointer ABI is consistent with the rest of the runtime (all heap cells are u64; ptrtoint/inttoptr round-trips). Worth knowing that Rust's extern "C" could accept *const Word/ContFn directly on the Rust side and the IR could pass ptr — eliminating two as usize as *const Word casts at the cost of a non-uniform ABI. Current shape is the right trade-off for v1; flag for a future "type the FFI better" pass.

6. Empty fact predicates fall back to per-clause. is_fact_predicate returns false for clauses.is_empty(). A 0-row table would actually work (the loop in fact_scan doesn't execute), but the per-clause path already handles empty predicates correctly so the asymmetry is fine. Worth a one-line comment justifying the exclusion ("empty predicates are routed via dynamic_only / per-clause; we don't need to duplicate the path here") so it doesn't read like an accidental gap.

7. Build.rs mtime archive-staleness bit again. Author flagged it explicitly: "stale embedded runtime → undefined plg_rt_fact_*; a rebuild refreshed it. Known stable limitation, not a regression." Confirmed real, not just theoretical (PR #15 review #3). Each new runtime ABI addition is one more opportunity to hit it. Worth tracking — the longer this lives, the more dev-loop friction it adds, and the easier it is for a future contributor to spend hours debugging "undefined symbol" when the actual fix is cargo clean -p plg-runtime.

8. The golden-IR test migration is well-handled. fact_compiles_to_unify_and_continuation_jump, multi_clause_predicate_pushes_choice_points, and first_arg_indexing_emits_switch were all repointed to compound-column inputs that don't qualify for the fact-table path — preserving per-clause and indexing coverage rather than discarding it. The new fact_predicate_compiles_to_rodata_table test pins the new shape. The "repoint + add" choice was the right one.

9. The # Safety doc comments on the FFI fns state the contract concretely (table layout, retry_ptr provenance). Good — future contributors adding raising helpers have a template.

10. Concerns I would NOT flag (correctly out of scope): Stage B's first-arg index, Stage C's float/big-int/compound columns, the deferred unsafe bounds-elision seam. All are explicitly recorded in FACT_TABLE.md as later work, and the PR description names them.

What's good

  • The musttail-not-Rust-call discipline is the load-bearing call, and the comment in facts.rs documents why — not just what — so it survives a future "this could be cleaner if Rust called k directly" refactor pressure.
  • Pre-binding marks for the CP (capture clean state, rewind, push_cp, rebind) is the conventional shape and the comments make it discoverable.
  • 9 integration tests cover: program-order enumeration, ground success/failure, int-column both directions, findall/call re-entry, recursive rule over a fact table, --limit, the compound-column fallback, and undefined → existence_error. Strong end-to-end pinning.
  • deep_recursion_runs_in_constant_c_stack is the right test for the musttail claim — 2000-deep recursion under a 512KB stack. The fact-table path now participates in that, so any future regression that breaks the musttail discipline shows up there.
  • No half-encoded float/big-int columns. Stage A is honest about what it supports; the test compound_column_predicate_stays_per_clause pins the fallback. Future Stage C can add encoded compounds without unwinding a half-baked float path.
  • Per-row rewind on failure (the m.rewind_to(clean_t, clean_h) at the bottom of the failed-row branch) keeps the trail/heap clean across attempted rows. Failed rows don't accumulate residue for subsequent rows to dig out of.
  • FACT_TABLE.md is solid as a design doc. The "unsafe question (answered)" section is the right tone — names the candidate, names the bar, defers it, conditions on a measured win. Future "let's get_unchecked this loop" pressure has a documented "not without a profile" check.

Suggested order

#1 (measure or doc-downgrade checkpoint 2) and #2 (centralize the ContFn transmute) are worth doing before merge — small and they close out the most prominent rough edges. #3+ are observations and follow-ups.

## Review — Fact-table compilation, Stage A Substantial PR, and the architectural calls are right. The headline decision — **delivery is a `musttail` in the generated entry/retry IR, NOT a Rust call to `k` from the runtime helper** — is exactly the discipline that keeps recursion through a fact predicate constant-stack. The runtime helper finds/binds/pushes-CP and *returns*; the IR's `deliver:` block tail-calls. Without this, a recursive `path/2` over an `edge` table would overflow the C stack proportional to recursion depth. Worth the prominent placement in the doc comments. Other strong calls: - **Per-predicate qualification.** `is_fact_predicate` runs per `(functor, arity)`; mixed programs compile with per-predicate routing. No all-or-nothing. - **Frame-reuse across choice points.** All CPs of one fact-predicate invocation point at the same frame (allocated once in `plg_rt_fact_first`). N solutions don't allocate N frames. - **Pre-binding rewind-then-push-CP-then-rebind.** The CP captures the *pre-binding* trail/heap marks, so backtracking through this row undoes only what this row did. Documented in comments. - **No registry change.** The fact-table entry is registered under the same `pred_symbol`, so `call`/`findall`/query dispatch reach it like any other predicate. - **`is_fact_predicate` narrows columns to atom + i61-integer.** Compound/float/big-int correctly fall back to per-clause (proven by `compound_column_predicate_stays_per_clause`). Half-encoding floats now would have been the wrong scope expansion. - **Last-row CP elision.** `if i + 1 < nrows { push_cp(...) }` saves an allocation when the matching row is the last. Issues below, ordered by impact. ### Real concerns **1. Stage A full-scan with no first-arg index is correct but doesn't yet test the headline claim.** The design's compile-time win (O(1) data emission vs O(N) functions) is structurally argued and the structural argument is sound. But the *runtime* claim — that fact-table queries stay reasonable at 100k facts — leans on Stage B's binary-search index, which isn't here. A `parent(tom, X)` against a 100k-row table scans all 100k rows linearly per solution. Checkpoint 2 in FACT_TABLE.md asks for "a 100k-fact predicate compiles in roughly constant data-emission time; record IR size + clang time before/after." The PR has 5000 facts compiling in ~125ms structurally but no measured 100k number. Worth either landing the benchmark now (it's a one-off shell script — doesn't need to be CI) or marking checkpoint 2 explicitly as "structurally ✅, measurement deferred" in the doc. Right now FACT_TABLE.md and the PR description both call it ✅, which is ahead of the evidence. **2. The `unsafe transmute::<usize, ContFn>` is repeated at two sites.** `facts.rs`: ```rust // fact_scan: let retry: ContFn = unsafe { std::mem::transmute::<usize, ContFn>(m.heap[frame + RETRY] as usize) }; // plg_rt_fact_next: m.k_fn = unsafe { std::mem::transmute::<usize, ContFn>(m.heap[frame + KFN] as usize) }; ``` Each one is the same invariant: "a heap cell stores a ContFn we wrote via `ptrtoint` in the IR." A helper: ```rust unsafe fn read_contfn(word: u64) -> ContFn { unsafe { std::mem::transmute::<usize, ContFn>(word as usize) } } ``` documents the invariant once and centralizes the unsafe surface. Stage B/C will add more raise sites; centralizing now is cheap. **3. The double-unify-and-rewind on a matching row is correct but wasteful.** `fact_scan`'s match path: 1. Inner loop unifies each col → bindings on trail 2. `rewind_to(clean_t, clean_h)` undoes step 1 3. `push_cp(retry, frame)` captures clean trail height 4. Inner loop re-unifies — bindings re-pushed onto trail That's `2N` unifications per matching row in the success case. For a 100k-fact predicate where every row matches (e.g. `findall(X, fact(X), L)`), that's 200k unification ops where 100k would suffice. A future optimization can: - Capture `trail_mark = m.trail.len()` *before* the first unify - On match, `push_cp(retry, frame)` records the captured mark as the restore-point - Skip the rewind and the second unify Defer to Stage B alongside the index, but worth noting now while the design is fresh. The current shape is correct; that's what matters for Stage A. ### Small observations **4. The asymmetry between `plg_rt_fact_first` and `plg_rt_fact_next`.** `first` saves `m.k_fn`/`m.k_env`/`m.qbarrier` into the frame but does *not* restore them after `fact_scan` (it relies on the invariant that `m.k_fn` is untouched at the time `deliver:` reads it). `next` *does* restore them because the solve driver may have overwritten them between CP push and CP pop. The asymmetry is correct, but a one-line comment in `first` saying "no restore needed: the caller's k_fn is still in m.k_fn from the entry frame" would save a future reader the analysis I just did. **5. The `i64`-as-pointer ABI** is consistent with the rest of the runtime (all heap cells are u64; `ptrtoint`/`inttoptr` round-trips). Worth knowing that Rust's `extern "C"` could accept `*const Word`/`ContFn` directly on the Rust side and the IR could pass `ptr` — eliminating two `as usize as *const Word` casts at the cost of a non-uniform ABI. Current shape is the right trade-off for v1; flag for a future "type the FFI better" pass. **6. Empty fact predicates fall back to per-clause.** `is_fact_predicate` returns `false` for `clauses.is_empty()`. A 0-row table would actually work (the loop in `fact_scan` doesn't execute), but the per-clause path already handles empty predicates correctly so the asymmetry is fine. Worth a one-line comment justifying the exclusion ("empty predicates are routed via `dynamic_only` / per-clause; we don't need to duplicate the path here") so it doesn't read like an accidental gap. **7. Build.rs mtime archive-staleness bit again.** Author flagged it explicitly: "stale embedded runtime → undefined `plg_rt_fact_*`; a rebuild refreshed it. Known stable limitation, not a regression." Confirmed real, not just theoretical (PR #15 review #3). Each new runtime ABI addition is one more opportunity to hit it. Worth tracking — the longer this lives, the more dev-loop friction it adds, and the easier it is for a future contributor to spend hours debugging "undefined symbol" when the actual fix is `cargo clean -p plg-runtime`. **8. The golden-IR test migration** is well-handled. `fact_compiles_to_unify_and_continuation_jump`, `multi_clause_predicate_pushes_choice_points`, and `first_arg_indexing_emits_switch` were all repointed to compound-column inputs that *don't* qualify for the fact-table path — preserving per-clause and indexing coverage rather than discarding it. The new `fact_predicate_compiles_to_rodata_table` test pins the new shape. The "repoint + add" choice was the right one. **9. The `# Safety` doc comments on the FFI fns** state the contract concretely (table layout, retry_ptr provenance). Good — future contributors adding raising helpers have a template. **10. Concerns I would NOT flag (correctly out of scope):** Stage B's first-arg index, Stage C's float/big-int/compound columns, the deferred `unsafe` bounds-elision seam. All are explicitly recorded in FACT_TABLE.md as later work, and the PR description names them. ### What's good - **The musttail-not-Rust-call discipline** is the load-bearing call, and the comment in `facts.rs` documents *why* — not just what — so it survives a future "this could be cleaner if Rust called k directly" refactor pressure. - **Pre-binding marks for the CP** (capture clean state, rewind, push_cp, rebind) is the conventional shape and the comments make it discoverable. - **9 integration tests** cover: program-order enumeration, ground success/failure, int-column both directions, `findall`/`call` re-entry, recursive rule over a fact table, `--limit`, the compound-column fallback, and undefined → existence_error. Strong end-to-end pinning. - **`deep_recursion_runs_in_constant_c_stack`** is the right test for the musttail claim — 2000-deep recursion under a 512KB stack. The fact-table path now participates in that, so any future regression that breaks the musttail discipline shows up there. - **No half-encoded float/big-int columns.** Stage A is honest about what it supports; the test `compound_column_predicate_stays_per_clause` pins the fallback. Future Stage C can add encoded compounds without unwinding a half-baked float path. - **Per-row rewind on failure** (the `m.rewind_to(clean_t, clean_h)` at the bottom of the failed-row branch) keeps the trail/heap clean across attempted rows. Failed rows don't accumulate residue for subsequent rows to dig out of. - **FACT_TABLE.md is solid as a design doc.** The "unsafe question (answered)" section is the right tone — names the candidate, names the bar, defers it, conditions on a measured win. Future "let's `get_unchecked` this loop" pressure has a documented "not without a profile" check. ### Suggested order #1 (measure or doc-downgrade checkpoint 2) and #2 (centralize the ContFn transmute) are worth doing before merge — small and they close out the most prominent rough edges. #3+ are observations and follow-ups.
⏺ Done — all gates green, clippy + fmt clean, refactor behavior-preserving
All checks were successful
CI - Linux / CI - Linux x86_64 (pull_request) Successful in 1m33s
40d6f049e3
(fact_table 9, golden 13, differential byte-identical to the oracle).

  Review disposition (PR #20)

  Addressed before merge:
  - #1 (checkpoint-2 measurement) — measured it: 100k 2-column facts → one
  .rodata table + two functions, ~1.0s total compile (plgc+clang), 4.5M
  binary. Recorded the number in FACT_TABLE.md checkpoint 2 and split the
  claim explicitly: this is the compile-time/footprint win; query latency at
  scale (full-scan ~90ms worst-case over 100k rows) is not covered and awaits
  Stage B's index. That removes the " ahead of the evidence" gap the
  reviewer flagged.
  - #2 (duplicated transmute) — added one unsafe fn read_contfn(word) ->
  ContFn with the shared-invariant doc; both sites (fact_scan retry,
  plg_rt_fact_next k_fn) now call it. Centralizes the unsafe surface before
  B/C add more.

  Small comments added:
  - #6 — note on why empty predicates are excluded (routed elsewhere, not an
  accidental gap).
  - #4 — the "no k restore needed in fact_first" comment was already in
  place; left as is.

  Deferred/tracked (the reviewer agreed these are not Stage A):
  - #3 (double-unify-and-rewind, 2N unifications on a match) — fold into
  Stage B: capture trail_mark before the first unify, push the CP with that
  mark, and skip the rewind + second unify. I'll do it alongside the index.
  - #5 (type the FFI better — *const Word/ContFn instead of i64) — future
  cross-cutting pass; current uniform-i64 ABI is the v1 trade-off.
  - #7 (build.rs mtime staleness) — real recurring dev-loop friction;
  tracking, not a Stage A code change. (It's the artifact = "staticlib"
  nightly fix or a just-level guard; out of scope here.)

  #8–#10 were praise / correctly-out-of-scope (Stage B index, Stage C
  columns, deferred unsafe seam).
navicore deleted branch fact-table-stage-A 2026-06-18 17:54:34 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
navicore/patch-prolog!20
No description provided.