Skip to content

Commit 82ce4c3

Browse files
committed
feat(contract): ClassView::compute_dag + ComputeEdge + acyclic gate (probe Inc 0)
Inc 0 of probe-excel-compute-dag-v1 — lands the one named Core gap (ClassView::compute_dag), the recompute-dispatch home every computed-field AR consumer needs (Odoo @api.depends / medcare / woa / q2, all reduce to a sheet) and the NNUE-incremental existence-proof shape. - ComputeEdge {target: u8, inputs: &'static [u8]}: harvest-sourced recompute edge (emitted_by target <- depends_on inputs), const-constructible like MethodSig/ActionDef. Field positions index the class FieldMask. - ClassView::compute_dag(class) -> &[ComputeEdge]: default &[] (zero-fallback); layout-preserving default-method (mirrors value_schema), stores nothing on the row, zero NODE_ROW_STRIDE/ENVELOPE_LAYOUT_VERSION impact. - compute_dag_is_acyclic: the registry-build gate -- a cyclic recompute DAG (formula loop / @api.depends cycle / self-loop) is rejected at build (Kahn over <=64 positions, allocation-free; out-of-range ignored, no panic). +4 tests (default-empty, acyclic-chain, 2/self/3-cycle rejected, out-of-range ignored); 10/10 class_view; clippy -D warnings + fmt clean. Board: LATEST_STATE Contract Inventory prepended. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcpLeEC3XK8Eye53GKBVvi
1 parent 0a36acf commit 82ce4c3

2 files changed

Lines changed: 186 additions & 0 deletions

File tree

.claude/board/LATEST_STATE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@
8989

9090
## Current Contract Inventory (lance-graph-contract)
9191

92+
> **2026-06-18 — ADDED (probe-excel-compute-dag-v1 Inc 0, the `compute_dag` Core gap)**: `lance_graph_contract::class_view::{ComputeEdge, compute_dag_is_acyclic}` + `ClassView::compute_dag(class) -> &[ComputeEdge]` (default `&[]`, zero-fallback). `ComputeEdge {target: u8, inputs: &'static [u8]}` is the harvest-sourced recompute edge (`emitted_by` target ← `depends_on` inputs; field positions index the class `FieldMask`), `const`-constructible like `MethodSig`/`ActionDef` (the harvest IS the manifest). `compute_dag_is_acyclic` is the **registry-build gate** — a cyclic recompute DAG (formula loop / `@api.depends` cycle / self-loop) is rejected at build (Kahn over ≤64 positions, allocation-free; out-of-range positions ignored, no panic, mirrors `FieldMask::from_positions`). This is the Core home for computed-field recompute *dispatch* that EVERY computed-field AR consumer needs (Odoo `@api.depends`, Excel formulas, medcare lab-trends, woa calc, q2 cells — they reduce to a sheet; `E-EXCEL-SHADER-PROJECTION`) and the NNUE-incremental existence-proof shape (`E-CHESS-TENSOR-PROVEN`). **Layout-preserving**: a default trait method + a free fn, resolution metadata ABOVE the SoA, stores nothing on the row, zero `NODE_ROW_STRIDE`/`ENVELOPE_LAYOUT_VERSION` impact (core-gap-auditor's EXTEND-CORE, never an adapter-state hack). The instance recompute that consumes it is gated per-cell by the cycle-aware `write_row` (`E-SOA-CYCLE-OWNERSHIP`). Additive, zero-dep; +4 tests (default-empty, acyclic-chain, cycle/self-loop/3-cycle rejected, out-of-range ignored); 10/10 class_view, clippy/fmt clean. Sibling `ClassView::constraints` (`validation_kind`-sourced) deferred to Inc-follow-up. Plan: `.claude/plans/probe-excel-compute-dag-v1.md`. Branch `claude/particle-wave-click-epiphany`.
93+
9294
> **2026-06-18 — ADDED (D-DO-ARM-1, the OGAR DO arm)**: `lance_graph_contract::action::{ActionState, StateGuard, ActionDef, ClassActions, actions_for, effective_actions, ActionInvocation}` — the Perdurant DO arm completing the OGAR IR (the action-axis sibling of `codegen_manifest`'s `MethodSig`/THINK). Both the 4-agent `sale_order` AR→DO probe (runtime-archaeologist) AND the merged cross-repo PR survey (ruff/OGAR/lance-graph/openproject/tesseract) agreed this was the ONE missing wire: the THINK arm (`classid → ClassView`, `has_function → MethodSig`) is converged + merged; the DO-arm `ActionInvocation`/`ActionDef` type was ABSENT. **`ActionDef`** (static, `const`-constructible, all `&'static`/`Copy`): `predicate` (= harvested `has_function` method), `object_class` (classid), `exec` (`ExecTarget` incl `SurrealQl`), `guard` (`StateGuard` = KausalSpec field==value), `required_role` (RBAC), `overrides` (OGAR `classid→ClassView` inheritance). **`ClassActions`+`actions_for`** (zero-fallback) mirror `ClassMethods`/`methods_for`. **`effective_actions(parent, child)`** = OGAR inheritance on the action axis (child overrides parent by predicate). **`ActionInvocation`** (dynamic, `Copy`): lifecycle `ActionState{Pending→Committed|Failed|Cancelled}` (sticky terminals), S2.5 `cycle` stamp, idempotency/trace keys, HLC `emitted_at_millis`. **`ActionInvocation::commit(def, actor, impact, now)`** is the gated egress — RBAC FIRST (`auth::ActorContext` must hold `required_role` or be admin → else `Failed`), THEN MUL impact (`mul::GateDecision`: `Flow→Committed`+stamped, `Hold→`Pending/escalate, `Block→Cancelled`). This IS "commit to the external consumer (odoo/openproject/woa/tesseract) after the cycle decides sound." Dispatched via `UnifiedStep`/`ExecTarget`, NOT a per-crate endpoint. Additive, zero-dep. +5 tests green. Consumer reference: `docs/OGAR_CONSUMER_API.md`. Branch `claude/soa-write-deinterlace-inc2`.
9395

9496
> **2026-06-18 — ADDED (D-UNICHARSET-KEYSTONE, classid → ClassView → adapter wiring)**: `lance_graph_contract::unicharset_adapter::{UniCharSetStore, UniCharCall, UniCharOut, DispatchError, invoke_unicharset}` — steps 2–3 of `PROBE-OGAR-ADAPTER-UNICHARSET`, the keystone composing the proven `UniCharSet` adapter through the OGAR Core's three movable parts. `invoke_unicharset(registry, store, classid, call)`: (1) **ClassView composition gate** — `codegen_manifest::methods_for(registry, classid)` must list the call's method (the harvested `has_function` manifest), else `MethodNotComposed` (zero-fallback: an unconfigured classid composes nothing); (2) **content-store tier** — `UniCharSetStore::unicharset(classid)`, a consumer-provided trait (dependency-inverted like `ClassView`/`PlannerContract`; the adapter holds NO state — `I-VSA-IDENTITIES`); (3) **adapter leaf** — routes to `UniCharSet::{id_to_unichar, unichar_to_id}`. DO-in (`UniCharCall`) / DO-out (`UniCharOut`, zero-copy borrow). **Byte-parity inherited** from `UniCharSet` (112/112); the keystone proves the dispatch path is faithful (the `NULL`→space edge survives it), the gate works, and there is **no Core gap** (the doctrine's iron guard holds — the variable-length bijection rides the content tier cleanly). NOT routed through the heavy `OrchestrationBridge` (cross-subsystem router); this is the adapter-invocation primitive a `UnifiedStep` calls. Additive, zero-dep. +5 tests; clippy `--all-targets -D warnings` + fmt clean. Completes the core-first doctrine END-TO-END for the unicharset leaf (`E-CPP-KEYSTONE-1`).

crates/lance-graph-contract/src/class_view.rs

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,79 @@ impl FieldMask {
138138
}
139139
}
140140

141+
/// One recompute edge in a class's **compute DAG**: field position `target` is
142+
/// (re)computed from the field positions in `inputs`.
143+
///
144+
/// Harvest-sourced — `target` is the `emitted_by` field, `inputs` are its
145+
/// `depends_on` precedents (Odoo `@api.depends`, an Excel formula's referenced
146+
/// cells, a chess-eval feature's inputs). All fields are `&'static` so a
147+
/// generated `const DAG: &[ComputeEdge] = &[..]` compiles (the harvest IS the
148+
/// manifest — mirrors [`crate::codegen_manifest::MethodSig`] /
149+
/// [`crate::action::ActionDef`]). Positions index the class's [`FieldMask`]
150+
/// (0..[`FieldMask::MAX_FIELDS`]), matching [`ClassView::fields`].
151+
///
152+
/// This is the Core home for recompute *dispatch* (`E-EXCEL-SHADER-PROJECTION` /
153+
/// `probe-excel-compute-dag-v1`): the manifest lives ABOVE the SoA (resolution
154+
/// metadata, stores nothing on the row); no adapter carries its own `@api.depends`
155+
/// table (`core-first-transcode-doctrine` — that would be the Adapter-State-Leak).
156+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157+
pub struct ComputeEdge {
158+
/// Field position this edge recomputes (the `emitted_by` target).
159+
pub target: u8,
160+
/// Field positions this target reads (its `depends_on` precedents).
161+
pub inputs: &'static [u8],
162+
}
163+
164+
/// Whether a class's `compute_dag` is **acyclic** — the registry-build gate.
165+
///
166+
/// A cyclic recompute DAG (a formula loop `A=B+1, B=A+1`, or a `@api.depends`
167+
/// cycle) MUST be rejected at registry-build: it has no topological order and
168+
/// would never converge. Returns `false` on any cycle (incl. a self-loop
169+
/// `target ∈ inputs`). Considers only positions `< FieldMask::MAX_FIELDS`;
170+
/// out-of-range targets/inputs are ignored (no panic, mirrors
171+
/// [`FieldMask::from_positions`]). Allocation-free (≤ 64 positions).
172+
#[must_use]
173+
pub fn compute_dag_is_acyclic(edges: &[ComputeEdge]) -> bool {
174+
const N: usize = FieldMask::MAX_FIELDS as usize; // 64
175+
// deps[t] = bitmask of in-range positions that target `t` depends on.
176+
let mut deps = [0u64; N];
177+
let mut is_target = 0u64;
178+
for e in edges {
179+
if (e.target as usize) >= N {
180+
continue;
181+
}
182+
is_target |= 1u64 << e.target;
183+
for &inp in e.inputs {
184+
if (inp as usize) < N {
185+
deps[e.target as usize] |= 1u64 << inp;
186+
}
187+
}
188+
}
189+
// Kahn: leaves (non-targets) are resolved; peel any target whose deps are all
190+
// resolved. If a round makes no progress with targets remaining → cycle.
191+
let mut resolved = !is_target;
192+
let mut remaining = is_target;
193+
loop {
194+
if remaining == 0 {
195+
return true;
196+
}
197+
let mut progressed = false;
198+
let mut r = remaining;
199+
while r != 0 {
200+
let t = r.trailing_zeros();
201+
r &= r - 1;
202+
if deps[t as usize] & !resolved == 0 {
203+
resolved |= 1u64 << t;
204+
remaining &= !(1u64 << t);
205+
progressed = true;
206+
}
207+
}
208+
if !progressed {
209+
return false; // stuck with targets remaining → cycle
210+
}
211+
}
212+
}
213+
141214
/// The class as a **meta lookup that flies above the SoA** — the resolver trait.
142215
///
143216
/// An implementor (in `lance-graph-ontology`, over the OGIT cache) is the
@@ -246,6 +319,24 @@ pub trait ClassView {
246319
// signed / fingerprint / …), it adds no new property.
247320
crate::canonical_node::ValueSchema::Full
248321
}
322+
323+
/// The class's **recompute DAG** — the topological manifest of which fields
324+
/// recompute from which (the `emitted_by` + `depends_on` harvest), the Core
325+
/// home for computed-field dispatch (Odoo `@api.depends`, Excel formulas,
326+
/// chess-eval features; `probe-excel-compute-dag-v1`).
327+
///
328+
/// Default `&[]` — the zero-fallback: an unconfigured class has no computed
329+
/// fields (mirrors `compute_dag`'s no-panic siblings). An implementor returns
330+
/// a generated `const &[ComputeEdge]`; the registry MUST validate it with
331+
/// [`compute_dag_is_acyclic`] at build (a cyclic DAG is rejected, never
332+
/// recomputed). Layout-preserving: resolution metadata above the SoA, stores
333+
/// nothing on the row, never a `NODE_ROW_STRIDE`/`ENVELOPE_LAYOUT_VERSION`
334+
/// change. The instance recompute that consumes this is gated per-cell by the
335+
/// cycle-aware `write_row` (`E-SOA-CYCLE-OWNERSHIP`).
336+
#[inline]
337+
fn compute_dag(&self, _class: ClassId) -> &[ComputeEdge] {
338+
&[]
339+
}
249340
}
250341

251342
/// One populated field to render — the late-resolved `label` + its `predicate` key.
@@ -319,6 +410,99 @@ mod tests {
319410
}
320411
}
321412

413+
// ── compute_dag (probe-excel-compute-dag-v1, Inc 0) ──────────────────────
414+
415+
/// Default `compute_dag` is the zero-fallback empty manifest (no computed
416+
/// fields for an unconfigured class).
417+
#[test]
418+
fn compute_dag_default_is_empty() {
419+
let c = FakeClasses { invoice: vec![] };
420+
assert!(c.compute_dag(7).is_empty());
421+
assert!(c.compute_dag(0).is_empty());
422+
}
423+
424+
/// `const`-constructible manifest — the exact shape a generated
425+
/// `const DAG: &[ComputeEdge]` emits (a chain: f2 = g(f1), f1 = h(f0)).
426+
const SAMPLE_DAG: &[ComputeEdge] = &[
427+
ComputeEdge {
428+
target: 1,
429+
inputs: &[0],
430+
},
431+
ComputeEdge {
432+
target: 2,
433+
inputs: &[1],
434+
},
435+
];
436+
437+
#[test]
438+
fn compute_dag_acyclic_chain_passes() {
439+
assert!(
440+
compute_dag_is_acyclic(SAMPLE_DAG),
441+
"a dependency chain f0→f1→f2 is acyclic"
442+
);
443+
assert!(compute_dag_is_acyclic(&[]), "empty dag is acyclic");
444+
// a target reading a non-computed leaf is fine
445+
assert!(compute_dag_is_acyclic(&[ComputeEdge {
446+
target: 5,
447+
inputs: &[3, 4]
448+
}]));
449+
}
450+
451+
#[test]
452+
fn compute_dag_cycle_is_rejected() {
453+
// f0 = g(f1), f1 = h(f0) — a 2-cycle, no topological order.
454+
let two_cycle = &[
455+
ComputeEdge {
456+
target: 0,
457+
inputs: &[1],
458+
},
459+
ComputeEdge {
460+
target: 1,
461+
inputs: &[0],
462+
},
463+
];
464+
assert!(
465+
!compute_dag_is_acyclic(two_cycle),
466+
"a formula loop must be rejected at registry-build"
467+
);
468+
// self-loop f0 = g(f0)
469+
assert!(!compute_dag_is_acyclic(&[ComputeEdge {
470+
target: 0,
471+
inputs: &[0]
472+
}]));
473+
// 3-cycle f0→f1→f2→f0
474+
assert!(!compute_dag_is_acyclic(&[
475+
ComputeEdge {
476+
target: 1,
477+
inputs: &[0]
478+
},
479+
ComputeEdge {
480+
target: 2,
481+
inputs: &[1]
482+
},
483+
ComputeEdge {
484+
target: 0,
485+
inputs: &[2]
486+
},
487+
]));
488+
}
489+
490+
#[test]
491+
fn compute_dag_out_of_range_positions_ignored() {
492+
// target/inputs >= MAX_FIELDS (64) are ignored, never folded → no panic,
493+
// no false cycle (mirrors FieldMask::from_positions).
494+
assert!(compute_dag_is_acyclic(&[
495+
ComputeEdge {
496+
target: 64,
497+
inputs: &[0]
498+
}, // ignored target
499+
ComputeEdge {
500+
target: 5,
501+
inputs: &[200]
502+
}, // input ignored → leaf-only target
503+
]));
504+
}
505+
322506
#[test]
323507
fn field_mask_is_presence_bits() {
324508
let m = FieldMask::from_positions(&[0, 2]); // amount + partner populated, tax absent

0 commit comments

Comments
 (0)