Companion to diagrams/algorithm_layer_n.dot.
Renders the per-candidate flow inside one BFS layer of the
unified set-search engine's shared private
_explore_layers helper, called by all three public entries
(monotonic_solve,
gaps_solve, contradictions_solve) which live side-by-side
in
solve.rs.
The layer-wrapping loop (enumerate → process → close layer →
recompute alive → next layer) sits one level above.
monotonic_solve adds early-terminate on first goal-sat
(returns Solution immediately) and omits LatticeProof
collection; gaps_solve + contradictions_solve always
exhaust and collect into LatticeProof. Otherwise the flow
below is the same for all three. The S1.5b.21 refactor
extracts _explore_layers from the existing
monotonic_solve body — the loop is never duplicated
across the three entries.
Design context — 2026-05-28. Earlier drafts of this
document described separate "monotonic" and "lattice"
engine flows; the lattice flow carried multi-parent
integrate, per-set kb storage of every visited commitment,
per-parent is_unconditional_at checks, and a runtime
sanity step. Under M1's monotone + order-commutative
saturation, those steps reduce to idempotent multi-writes
of the same nogood/forced-positive or to always-True
predicates — mechanical redundancy. The flow below uses
flat root-writes (single write to root._nogoods /
root.facts instead of per-parent bubble) and bounded
per-set storage (SetNodes only when store_lattice=True;
SOLUTION kbs always; dead unsat-cores under
contradictions_solve). The same-day engine-design
correction also collapsed the "two engines" framing into
one unified engine with three sibling public entries —
all in
solve.rs,
sharing the helper described here. See
[project-set-search-unified memory] + the 2026-05-28
conversation; README for the phase shape.
dot -Tsvg diagrams/algorithm_layer_n.dot -o diagrams/algorithm_layer_n.svg
# or
dot -Tpng diagrams/algorithm_layer_n.dot -o diagrams/algorithm_layer_n.pngThe diagram uses six colour bands:
| colour | meaning |
|---|---|
| light blue | setup / data flow / try_commitment_set |
| light yellow | decision diamonds |
| light gray | optional / opt-in feature (hash dedup, sanity) |
| pale green | ALIVE outcome (intermediate) |
| medium sea green | SOLUTION outcome (collected; terminal under early_terminate) |
| salmon | DEAD outcome |
| plum | flat root-write (nogood emit or unconditional merge) |
All three live in
solve.rs
and call the shared private
_explore_layers(entry: Literal["monotonic","gaps","contradictions"], …)
helper (the per-candidate flow described in §3 below).
S1.5b.21 extracts the helper from today's monotonic_solve
body; S1.5b.21 + S1.5b.23 add the two new wrappers.
# All three: ein-infer/src/solve.rs
def monotonic_solve(
root_kb,
*,
max_set_size: int = 5,
config: SolverConfig | None = None,
mode: Mode = Mode.SOLVE, # legacy; raises for non-SOLVE
dumper: MonotonicDumper | None = None,
max_time: float | None = None,
max_enterings: int | None = None,
) -> tuple[Verdict, MonotonicStats]:
"""SOLUTION MODE. Early-terminate on first goal-sat.
Per-candidate flow is the §3 algorithm below WITH
early-terminate at §3c.ii AND no LatticeProof collection.
Documented in s1.5b.5_monotonic_backbone.md.
Shipped under S1.5b.0 to S1.5b.10."""
def gaps_solve(
root_kb,
*,
max_set_size: int = 5,
config: SolverConfig | None = None,
store_lattice: bool = False, # orthogonal storage toggle
dumper: LatticeDumper | None = None,
max_time: float | None = None,
max_enterings: int | None = None,
) -> tuple[Ambiguity, LatticeStats]:
"""GAPS CONTRACT: always Ambiguity. Per-candidate flow
is the §3 algorithm below WITHOUT early-terminate,
collecting every satisfying commitment into
proof.solutions. State-hash dedup MERGE is auto-disabled
under gaps_solve (correctness — distinct satisfying
commitments must register separately) even when
store_lattice=True. Backbone: S1.5b.21."""
def contradictions_solve(
root_kb,
*,
max_set_size: int = 5,
config: SolverConfig | None = None,
store_lattice: bool = False,
dumper: LatticeDumper | None = None,
max_time: float | None = None,
max_enterings: int | None = None,
) -> tuple[Contradiction, LatticeStats]:
"""CONTRADICTIONS CONTRACT: always Contradiction.
Per-candidate flow is the §3 algorithm below WITHOUT
early-terminate, collecting every dead commitment into
proof.dead_commitments. State-hash dedup merge is safe
here (refutation map is multi-label by construction).
Backbone: S1.5b.23."""store_lattice toggles whether LatticeProof.kb_index
(per-visited-commitment SetNode storage) is built.
Independent of which entry is called; not present on
monotonic_solve (no storage by design).
For layer N with frozen A_1, …, A_{N-1} and the current
root._nogoods:
Sort every set in A_{N-1} canonically (Q1.5b.3 — tuple of
sorted FactIds); for each prefix-class (sets agreeing on
their first N-2 elements), pair every two members
(a_1, …, a_{N-2}, b) and (a_1, …, a_{N-2}, c) with
b < c; emit (a_1, …, a_{N-2}, b, c) once. Each
size-N candidate produced exactly once.
Drop C if any of:
- some
(N-1)-subset ofCis not inA_{N-1}(covered by construction with prefix-join on closedA_{N-1}); matches_any_nogood(C, root._nogoods)— some learned clause is a subset ofC, so committing all ofCtriggers the known contradiction;- some element of
Cis no longer in the current alive set (a(not h)landed at root since layer-N-1closed — e.g. from a forced-positive bubble or another layer-Ncandidate's death within this same layer under Option A cadence).
The single all-in-one primitive
(commitment.rs):
fork ← root.fork()
for (rn, args) ∈ C:
fork.add_fact(Fact(rn, args, layer=REASONING, prov=from_hypothesis))
if ContradictionDetector(fork).detect():
return CommitmentSetResult(kind="dead-pre", unsat_core=…)
firings ← Saturator(fork).saturate()
if ContradictionDetector(fork).detect():
return CommitmentSetResult(kind="dead-post", firings=…, unsat_core=…)
return CommitmentSetResult(
kind="alive",
firings=firings,
unconditional_facts=extract_unconditional(fork, root, C),
)
Why fork from root, not from a parent. Saturation
commutativity guarantees sat(root ∪ C) = sat(P_i.kb ∪ (C \ P_i))
for any P_i ⊊ C. Earlier designs forked from a parent's
kb_snapshot to reuse its firings. Under flat root-writes the
root accumulates unconditional consequences of every prior
alive entering, so root.kb is closer to kb(C) than any
specific stored P_i.kb (whose snapshot froze at its layer).
Forking from root is at least as fast and avoids the storage
cost.
Unconditional-fact extraction. A fact f ∈ kb(C) \ root is
unconditional w.r.t. C iff its provenance chain (transitive
walk of provenance.premises_raw) never hits any
h_id ∈ frozenset(C). Equivalently — the derivation is valid
in (root, rules) alone. The walker terminates at non-rule
provenances (raw facts, hypothesis facts, forced-positive
promotions). See commitment::try_commitment_set.
Note (user, 2026-05-25): under M1 all hypotheses are positive facts; negative hypotheses are a follow-up. The pre-sat contradiction filter still applies but its yield is small — most deaths surface only after saturation.
Only when store_lattice=True and the caller is
contradictions_solve (NOT gaps_solve, NOT
monotonic_solve). Under gaps_solve, two distinct
satisfying commitments must register separately even when
their saturated kbs collide — the merge is incorrect there
(the GAPS contract is "enumerate every satisfying commitment
set"). Under monotonic_solve, store_lattice doesn't
exist and there's no kb_index to look into.
h ← state_hash(result.kb)
if h ∈ proof.kb_index:
E ← proof.kb_index[h] # existing SetNode
E.labels += (C,) # multilabel grow
record audit row marking C → merged_into = E
SKIP downstream — everything already recorded on E
advance to next candidate
else:
proof.kb_index[h] ← SetNode(canonical_set=C, …)
Even under gaps_solve with store_lattice=True, the
SetNode is created and added to kb_index but the merge
step is skipped — each commitment gets its own SetNode
record so distinct satisfying commitments stay distinct.
Cost: one state_hash(fork) (O(|fork.facts|)) per arrival;
one dict-by-int lookup. Free relative to the saturation we
just paid. Hash dedup pays off when:
- The puzzle has frequent cross-set kb-convergence
(
examples/zebra2-hints.einis a candidate fixture per Q1.5b.4.c); - Downstream processing (per-set storage + dumper writes)
is non-trivial — i.e.,
store_lattice=True.
Empirical evaluation lives in S1.5b.28 (fixtures) + S1.5b.30 (perf round).
Flat root-write — no per-parent bubble. Saturation commutativity makes "bubble through all parents" idempotent (every ancestor would receive the same clause, all eventually reaching root), so we write to root directly.
emit_nogood(root, frozenset(C), min_size=1)
# Subsumption-aware (nogoods.rs) — drops the clause if a
# strict subset is already in _nogoods; removes existing
# superset clauses; otherwise inserts.
if |C| == 1:
# Single-element death → also write (not h) into
# root._negated_facts so the next compute_alive() prunes h.
# If h's relation is (symmetric R), write the mirror too.
write_negated_fact(root, C[0])
# Entry-specific collection:
if caller is contradictions_solve:
proof.dead_commitments.append(DeadCommitment(
commitment=C, unsat_core=result.unsat_core,
learned_clause=frozenset(C), layer=N, kind=result.kind,
))
# else (monotonic_solve, gaps_solve): nothing further —
# the root._nogoods clause is sufficient for downstream pruning.
The unsat_core is the witness facts the detector returned
(pre-sat: the new hypothesis + the existing facts that
contradict it; post-sat: the conflict pair or (false)
witness with its provenance chain).
Why doesn't gaps_solve collect dead commitments? Because
the GAPS contract is "give me every satisfying commitment"
— the refutation map isn't part of that contract. The
_nogoods clause is sufficient for the engine's internal
pruning. If the caller wants both the solution set AND the
refutation map for the same puzzle, they call BOTH
gaps_solve and contradictions_solve; each entry's cost
profile is honest about what it returns.
The fork's saturated kb satisfies the goal. Behaviour is entry-specific:
# monotonic_solve:
if caller is monotonic_solve:
return Solution(kb=result.kb) # early-terminate
# gaps_solve:
if caller is gaps_solve:
proof.solutions.append(SolutionRecord(
commitment=C, kb=result.kb.snapshot(),
firings=result.firings, layer=N,
))
# No early-terminate — continue to find more solutions.
# contradictions_solve:
if caller is contradictions_solve:
# A satisfying commitment is not a refutation, so we
# don't record it into dead_commitments. We also don't
# build proof.solutions (not part of the contract).
# Just continue.
pass
Why snapshot only solutions. The explanation phase (P1.6)
walks the SOLUTION node's derivation_dag to chain facts
to goal; it never walks dead or non-satisfying-alive nodes.
So those don't need persisted kbs. Even with
store_lattice=True, the per-SetNode stored kb (in
proof.kb_index) is a snapshot reference shared with the
SolutionRecord — not a duplicate.
Flat root-merge of the unconditional facts, then the standard re-saturate / recompute-alive / forced-positive cascade. Same on all three entries.
for f ∈ result.unconditional_facts:
if root._fact_by_id(f.relation_name, f.args) is None:
root.add_fact(f); root._index_fact(f)
stats.facts_merged += 1
if any facts merged:
Saturator(root).saturate()
if ContradictionDetector(root).detect():
return Contradiction(unsat_core=…)
alive ← compute_alive(root)
# Forced-positive cascade: if alive shrinks to {h_unique},
# promote h_unique to a root fact with
# Provenance.from_rule("<forced-positive>", premises_raw=())
# so the chain walker treats it as a terminal. Re-saturate.
# Repeat until |alive| ≠ 1.
alive ← promote_forced_positives(root, alive)
if is_solved(root):
# Same entry-specific dispatch as §3c.ii but with the
# solution kb = root snapshot (the cascade landed it
# at root, not at the fork).
if caller is monotonic_solve:
return Solution(kb=root)
elif caller is gaps_solve:
proof.solutions.append(SolutionRecord(
commitment=C, # the breakthrough commitment
kb=root.snapshot(),
firings=(…),
layer=N,
))
# contradictions_solve: same as §3c.ii (no record)
Why this is sound: per-parent is_unconditional_at is
unnecessary. If f's chain at level N doesn't touch any
h ∈ C, then by the saturation-commutativity argument
(sat(root) ⊆ kb(P_i) for any P_i ⊆ C) the same chain is
valid at every ancestor — including root. Earlier drafts
checked is_unconditional_at(P_i) per parent; that predicate
is always True given a correct chain walk. Flat root-merge
suffices.
Why gaps_solve records the carrier C even when the
goal fires at root. The cascade that landed the goal at
root was triggered by C's unconditional facts; for the
explanation walk, C is the "breakthrough commitment" —
the set whose exploration produced the unconditional
consequences that closed the proof. P1.6 needs this to
render the "suppose C — derive the consequences — observe
the goal closes" narrative.
When the queue is exhausted:
A_Nis populated with the alive size-Ncommitments visited (for next-layer prefix-join).root._nogoodscarries every nogood emitted this layer.root.factscarries every unconditional fact merged this layer.alivereflects every back-propagated negative (single-element nogoods from layer-1 deaths + any forced negatives propagated up).proof.solutionscarries every satisfying commitment (under SOLVE-exhaustive / GAPS / CONTRADICTIONS).proof.dead_commitmentscarries every dead set with unsat_core (under CONTRADICTIONS only).- The orchestrator advances to layer
N+1(if budget remains and SOLVE-early-terminate didn't return).
If A_N is empty AND no solution was found AND alive is
empty → search complete with no remaining frontier;
final verdict (Q1.5b.10) is derived from the cumulative state.
After the layer loop ends without early termination:
| state at termination | verdict |
|---|---|
is_solved(root) true (cascade fired earlier) |
Solution(kb=root) |
alive empty |
Contradiction(unsat_core=…) |
depth cap reached with non-empty alive |
Ambiguity(branches=…, unresolved=A_N) |
(No LatticeProof attached — monotonic_solve doesn't
build one. Early-terminate from §3c.ii has already returned
in the happy path.)
Always returns Ambiguity:
Ambiguity(
branches = tuple(Solution(kb=s.kb) for s in proof.solutions),
unresolved = A_N, # surviving alive sets at depth cap
tree = None,
proof = proof # carries solutions, kb_index (if store_lattice), learned_nogoods
)
Caller interprets:
len(branches) == 0→ no solution found within depth cap (puzzle is unsolvable OR depth cap too low).len(branches) == 1→ uniquely solvable.len(branches) > 1→ genuine multi-solution.
Always returns Contradiction:
Contradiction(
unsat_core = frozenset().union(
*(d.unsat_core for d in proof.dead_commitments)
),
proof = proof # carries dead_commitments, kb_index, learned_nogoods
)
Caller interprets:
len(proof.dead_commitments) == 0→ no deaths observed (degenerate case; might still be solvable).- Non-empty → refutation map;
unsat_coreshows the facts implicated.
LatticeProof (S1.5b.22) carries proof.solutions (filled
by gaps_solve only), proof.dead_commitments (filled by
contradictions_solve only), proof.kb_index (filled when
store_lattice=True for either entry), proof.alive_at_end
= A_N, and proof.learned_nogoods = snapshot of
root._nogoods at termination.
Dropped relative to earlier (multi-engine) drafts:
find_parents/ multi-parent integrate (Q1.5b.4.b) — under monotone saturation the parent bubble for nogoods is N-way idempotent and the bubble for unconditional facts is always-True. Flat root-writes are equivalent and cheaper. Killed stages: S1.5b.24 (back-prop integrate), S1.5b.25 (per-set forced-positive mining).is_unconditional_at(P_i)per parent — by saturation commutativity, unconditional-at-C ⟹ unconditional-at-every-P_i. Defensive code with no semantic effect.- Per-set kb_snapshot for ALIVE intermediates — never
read except by the (dropped) multi-parent integrate. Only
SOLUTION nodes need persisted kbs (for P1.6's
derivation_dagwalk). - Sanity check (§3b in the old draft) — kept as an optional dev/regression tool (S1.5b.27) but not a runtime step. It's a one-time validation of the saturation commutativity premise; if it ever fails, the M1 ruleset has lost its monotone property and the entire engine collapses.
What stays:
- Apriori prefix-join + the two filters (subset + nogood + alive).
try_commitment_set(root, C)primitive — unchanged.- Flat
emit_nogood(root, frozenset(C))writeback. - Flat unconditional-fact merge into root + re-saturate + recompute alive + forced-positive cascade.
- Optional state-hash dedup as a perf flag (off by default, forced off under GAPS).
- Per-set audit dumper (mode-aware, S1.5b.23).
- Engine entry:
solve.rs— ein.py's wholemonotonic/package in one module. - Filter helpers:
nogoods.rs(matches_any_nogood,emit_nogood). - Commitment primitive:
commitment.rs(try_commitment_set). - State hash:
canon.rs. - Saturation commutativity premise:
README § Set-indexed search +
[
project-set-search-unifiedmemory]. - Mode + verdict trichotomy: Q1.5b.7.
- Per-set storage policy (post-merge): S1.5b.21 LatticeProof.
- Killed: S1.5b.24 — multi-parent integrate, S1.5b.25 — per-set forced positives.