Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/board/STATUS_BOARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
| **D-ONNX-INTAKE-4** | S4 numeric/quantization parity for real model blockers | Queued (conditional; not authorized) | IntegerExact / bounded FP / SourceParity / CertifiedMath kept distinct |
| **D-ONNX-INTAKE-5** | S5 whole-model forward pass and cost closure | Goal, not authorized | outputs vs ORT under declared tolerance; every cost stage reported |
| **D-TRACT-HARVEST-0** | ONNX graph as a DR#3 graph (nodes Identity, values Property, dep = do_ ∘ read_by) resolved through a DT64 ON×WHEN×DO registry (op signature → classid → native DO); Tract cold facts; ORT (optimizer off) oracle; `crates/tract-harvest-probe` | Lab probe implemented (this PR, redesigned to the graph); plan [d-tract-harvest-0-native-algebra-bridge-v1.md](../plans/d-tract-harvest-0-native-algebra-bridge-v1.md) §0 | 26 tests, 13 disable runs red; full model 4,146/7,620 nodes WHEN-true with inference-stage facts (§0.1), 0 refused for missing facts, >64 positions untiled, first unregistered groups Unsqueeze/Shape/Gather/Concat/MatMul |
| **D-TRACT-SHAPE-1** | Cold shape-subgraph folding (Const/Shape/Symbolic/Data classes; fold through the node's own WHEN/DO; stale-input refusal), shape-op registrations (Shape, Gather i64, Unsqueeze, Concat, Reshape, Cast i64, Erf via `vserf`, scalar-Y Where via `blend_i32_scalar`), producer-checked Tract fact admission | **In PR** (lab probe); plan [d-tract-harvest-0-native-algebra-bridge-v1.md](../plans/d-tract-harvest-0-native-algebra-bridge-v1.md) §0.2 | 40 tests, 9 disable runs red; Jina [1,10] coverage 4,146/0/3,474 → 6,404/56/1,160, 4,440 nodes fold in 7.3 ms; remaining blocker int64 shape arithmetic (owner decision) |

## D-TFLP — TFLite models on the native polyfill: int8 codes as Palette256 (2026-10-25)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# D-TRACT-SHAPE-1 — cold shape folding and ON × WHEN × DO admission of the shape ops

**Date 2026-10-30 · Status LAB PROBE · Plan [`d-tract-harvest-0-native-algebra-bridge-v1.md`](../../plans/d-tract-harvest-0-native-algebra-bridge-v1.md) §0.2 · Crate `crates/tract-harvest-probe`**

Every value is classed Const / Shape / Symbolic / Data. A node folds at resolution only through its own registration (WHEN TRUE, then its DO) and only when invariant under the source and the resolved input facts; a run with other input dims is refused. Registry v2 adds Shape, Gather (int64 data), Unsqueeze, Concat, Reshape, Cast i64→i64 and Erf (`vserf`); scalar-Y Where uses `blend_i32_scalar`.

A Tract fact is admitted only from the value's source-producer outlet (a matching label is not authority): typed 1,988 of 2,546 admitted, 558 rejected; inference 0 rejected. Tract `TDim` is read as int64. A Tract panic (out-of-range constant Gather index) is a frontend refusal.

**Jina coverage [WHEN-true, not-true, unregistered], pinned [1,10]:** 4,146 / 0 / 3,474 → **6,404 / 56 / 1,160**; 4,440 nodes fold in 7.3 ms, 1,972 folded values retained (~20 KB). The remaining shape blockers are int64 arithmetic (Mul 115, ConstantOfShape 59, Equal 58, Where 58, Div 56, …), which no ndarray word covers.

Fixtures (ORT 1.29.0 `ORT_DISABLE_ALL`): shape chain bit-exact; Erf ≤ 2 ULP vs ORT and f64 over 4,115 points. 40 tests; 9 disable runs red.

**OPEN:** int64 shape-arithmetic semantics (owner, then ndarray ask); axis as DO parameter vs classid per axis; `vserf` ~4x slower than Tract's erf; ConstantOfShape/Expand via ndarray core not yet registered; multi-tile and MatMul gates unchanged.
3 changes: 2 additions & 1 deletion .claude/board/entries/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ index row, (3) no duplicate entry id. Checks 1 and 2 are deliberately
opposite directions; the stranding this convention prevents shows up in
exactly one of them, never both.

504 entries, 2026-08-06 .. 2026-10-30.
505 entries, 2026-08-06 .. 2026-10-30.

| date | entry id | finding | file |
|---|---|---|---|
| 2026-10-30 | `D-TRACT-SHAPE-1` | | [2026-10-30-d-tract-shape-1-cold-shape-folding.md](2026-10-30-d-tract-shape-1-cold-shape-folding.md) |
| 2026-10-30 | `D-TRACT-HARVEST-0` | | [2026-10-30-d-tract-harvest-0-native-algebra-bridge.md](2026-10-30-d-tract-harvest-0-native-algebra-bridge.md) |
| 2026-10-30 | `D-DRCA-3` | | [2026-10-30-d-drca-3-dr4-method-routing.md](2026-10-30-d-drca-3-dr4-method-routing.md) |
| 2026-10-29 | `palette512-turbovec9-nsl-worksteal` | | [2026-10-29-palette512-turbovec9-nsl-worksteal.md](2026-10-29-palette512-turbovec9-nsl-worksteal.md) |
Expand Down
38 changes: 37 additions & 1 deletion .claude/plans/d-tract-harvest-0-native-algebra-bridge-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ ON × WHEN × DO per node: ON = its input Property positions; WHEN = a three-val
**OPEN (graph design):**
- More than 64 nodes or values needs several DR64 tiles; the full graph is only censused, not tiled.
- ~~Facts for the 5,074 values Tract's typed stage leaves unlabelled~~ CLOSED 2026-10-30, see §0.1.
- Registry entries for the shape-plumbing ops (`Unsqueeze`, `Shape`, `Gather` on shapes, `Concat`, `Reshape`) and the admitted f32 `MatMul`.
- ~~Registry entries for the shape-plumbing ops~~ registered 2026-10-30, see §0.2. Still open: the admitted f32 `MatMul`, and int64 shape arithmetic (§0.2).
- The plan clones constants per run; hot allocation is not yet minimal.
- Classids are lab-local; minting through OGAR is a separate decision.

Expand All @@ -79,6 +79,42 @@ The 169 refusals came entirely from missing facts. A further 226 nodes became re

Tests: `inference_stage_facts_cover_every_value_and_agree_with_typed` (tiny), `full_graph_facts_and_coverage` (Jina, counts pinned exactly), `undetermined_input_dim_gives_no_shape_and_typing_failure_is_recorded`, `dr3::tests::fact_sources_disagree_and_merge`. `unknown_shapes_refuse` re-pinned: `T` now has a Tract fact (the typed stage had merged it with `Z`), so `n_greater` is the first UNKNOWN. Disable runs, each red on the named tests: merge from typed only; undetermined dim as a symbol; typed failure propagated; `disagreements` always empty.

### 0.2 Cold shape folding (D-TRACT-SHAPE-1, 2026-10-30)

**Classification.** `fold::classify` gives every value a dependency class: `Const` (source constants only), `Shape` (constants and fully known input shapes), `Symbolic` (a shape with a symbolic dimension) or `Data` (an input value, or a shape the facts do not determine). A `Shape` node takes the class of its input's shape, every other node the strongest class of its inputs. Jina, inputs pinned `[1,10]`:

| op | Const | Shape | Symbolic | Data |
|---|---|---|---|---|
| Unsqueeze (888) | 0 | 830 | 0 | 58 |
| Shape (430) | 0 | 430 | 0 | 0 |
| Gather (373) | 0 | 371 | 0 | 2 |
| Concat (284) | 0 | 228 | 0 | 56 |
| Reshape (228) | 0 | 58 | 0 | 170 |
| all 7,620 nodes | 2,760 | 2,399 | 0 | 2,461 |

Unpinned, Tract's `analyse` fails at `/Flatten` (two symbols), so the Symbolic class is shown on the `shape_sym` fixture instead.

**Folding.** `fold::fold` executes a node at resolution only when its class is `Const` or `Shape` and either every input is a known constant or it is `Shape` over a fully known shape. It uses the node's own registration: lookup, WHEN must be TRUE, then the same DO a run would call. `plan::resolve` folds before binding, writes the folded values into the tile's constants (so later guards read them) and keeps folded nodes in `Plan::folded`, not in the steps. A run whose inputs differ from the resolved facts is refused before any step (`check_against_fact`), so a folded shape is never applied to an input it was not computed for. Symbolic and Data nodes stay dynamic.

**Registry v2.** Lab classids: `Shape` F32/I64/I32/Bool 0x0F60–0x0F63 (`start`/`end` clamped), `Gather` on int64 data 0x0F64 (`ndarray` `select`; constant indices in `[-d, d)`, else FALSE; run-time indices UNKNOWN), `Unsqueeze` I64/F32 0x0F65/0x0F66, `Concat` I64/F32 0x0F67/0x0F68 (`ndarray::concatenate`; equal non-axis dims, two different symbols UNKNOWN), `Reshape` I64/F32 0x0F69/0x0F6A (`allowzero = 0`, constant target, known input shape), `Cast` i64→i64 identity 0x0F11, `Erf` 0x0F33 (`ndarray::hpc::vml::vserf`, Bounded ≤ 2 ULP on [-5, 5]). Scalar-Y `Where` now calls `ndarray::simd::blend_i32_scalar` (one allocation and 28 B less per call on fixture A; no new kernel). `AttrRule.value: None` admits any integer attribute (an axis) read by the DO; a fixed value still keys a separate signature.

**Fact identity.** A Tract fact is admitted for a source value only if exactly one outlet of the stage carries the label and that outlet is the value's source producer by Tract's own naming rule (`dr3::source_producers`, `onnx/src/model.rs:122,150`); a matching label alone is not authority (`dr3::admit`). Jina: inference 7,937 admitted, 0 rejected; typed 1,988 of 2,546 full facts admitted, 558 rejected (557 from an outlet Tract renamed during typing, 1 label on two outlets). Coverage is unaffected because the inference stage covers every value. Tract's `TDim` (the type its `Shape` emits) is read as int64, the ONNX type. A Tract panic during `analyse` or typing is a frontend refusal (Tract indexes out of bounds on a constant out-of-range `Gather` index, `core/src/ops/array/gather.rs:75`).

**Coverage, Jina pinned `[1,10]`, [WHEN-true, WHEN-not-true, unregistered]:** registry v1 (§0.1) 4,146 / 0 / 3,474 → registry v2 with folding **6,404 / 56 / 1,160**. 4,440 nodes fold in 7.3 ms (facts 221 ms); 1,972 folded values are still read by non-folded nodes (~20 KB). Per op: Unsqueeze 888/0/0, Shape 430/0/0, Gather 371/0/2 (f32 embedding and bool data), Concat 284/0/0, Reshape 170/56/2 (56 UNKNOWN: target computed by unregistered int64 arithmetic; 2 bool data). Every registered shape-class node that does not fold is blocked by an upstream unregistered node, none by its own WHEN: `Unsqueeze` 181, `Cast` 117, `Concat` 57, `Mul` 2. The unregistered shape-class nodes are int64 arithmetic: `Mul` 115, `ConstantOfShape` 59, `Equal` 58, `Where` 58, `Div` 56, `Range` 4, `Expand` 2, `Add` 1, `LessOrEqual` 1, `And` 1, plus `Cast` to bool/float 2, `Cos`/`Sin`/`MatMul`/`Transpose` 1 each.

**Fixtures and oracle.** New in `tools/gen_tiny.py`, frozen ORT 1.29.0 `ORT_DISABLE_ALL`: `shape_fold` (the chain folds; `Reshape(F)` and `Unsqueeze(F)` run bit-exact), `shape_sym` (nothing folds; Reshape WHEN UNKNOWN, refused), `neg_gather_index` (ORT fails the run, Tract panics, the frontend refuses), `neg_concat` (ORT and Tract both reject), `erf` (fixture A with Erf; ≤ 2 ULP vs ORT), `erf_sweep` (4,115 points: dense [-6, 6], signed zeros, infinities, NaN, subnormals; max 2 ULP vs `math.erf` rounded to f32 and vs ORT; specials exact). `neg_unsupported` now uses `Acosh`.

**Cost (reported, `examples/cost.rs`, AVX-512 host, `target-cpu=native`):** `shape_fold` cold tile+fold+resolve 12.4 µs (2 steps, 4 folded), hot 1.7 µs, 37 allocations; `erf` 4,096 elements hot 8.5 µs vs Tract 2.0 µs (`vserf` alone 7.9 µs, 1.9 ns/element); fixture A hot 3.3 µs, 55 allocations (56 before scalar-Y Where); Jina slice tile+fold+resolve 20 µs (9 steps, 4 folded Constants).

Tests (40, all green): `shape_chain_folds_and_the_data_nodes_run_against_ort`, `folded_shapes_refuse_a_run_with_other_input_dims`, `a_node_reading_input_values_never_folds`, `a_symbolic_shape_is_not_folded_and_unknown_is_not_true`, `an_out_of_range_gather_index_is_refused_not_a_panic`, `a_concat_dimension_mismatch_is_refused_by_the_frontend`, `erf_in_the_graph_against_ort`, `erf_sweep_against_f64_and_ort`, and unit tests `a_label_alone_does_not_admit_a_fact`, `source_producers_follow_tract_naming`, `concat_never_aliases_two_symbols`, `gather_index_range_is_three_valued`, `reshape_target_rules`, `attr_rules_any_and_fixed`, `shape_start_end_clamp`. Re-pinned: `full_graph_facts_and_coverage` (typed 1,988 admitted, 558 rejected, coverage above), the RMSNorm slice (9 steps + 4 folded Constants), `unregistered_op_is_refused` (Acosh). Disable runs, each red on the named test: no folding; label as authority; two symbols aliased; Reshape UNKNOWN as TRUE; symbolic shape classed as known; no stale-input check; Gather range always TRUE; no panic catch; Shape `end` ignored. Not load-bearing (documented in the code): the fold's dependency-class skip, since a Symbolic or Data node never has every input known.

**OPEN (owner decisions):**
- int64 shape arithmetic (`Mul`, `Div`, `Add`, `Equal`, `Where`, `Range`) has no ndarray word; its overflow and division semantics (wrap or refuse; truncation; division by zero) need a decision before an ndarray ask.
- An axis attribute is a DO parameter under one classid per (op, dtype); the alternative is a classid per axis value.
- `Erf` is Bounded at 2 ULP; `vserf` is about 4x slower than Tract's `erf` on this host.
- `ConstantOfShape` and `Expand` map onto ndarray `from_elem` / `broadcast` and are not registered yet.
- Multi-tile orchestration and full Jina MatMul execution remain separate gates.

## 1. The acceptance chain, demonstrated

The chain under test: a source-attested ONNX subgraph is imported by Tract, admitted, lowered onto existing native methods, executed without Tract, and verified against ONNX Runtime (ORT). The chain holds for both fixtures: fixture A (tiny, synthetic) is bit-exact, and fixture B (a real model slice) is bounded. The numbers are in §4. Nothing here is certified, production or merged.
Expand Down
46 changes: 45 additions & 1 deletion crates/tract-harvest-probe/examples/cost.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,13 @@ fn measure(
let tract_ns = median(runs, || {
black_box(plan.run(tract_in()).unwrap());
});
println!("{label:>5} cold: source {src_us:8.1} µs | Tract facts {facts_us:8.1} µs | tile+resolve {resolve_us:7.1} µs ({} steps)", p.steps.len());
let retained: usize = p
.folded
.iter()
.flat_map(|b| p.graph.outs[b.node].iter())
.filter_map(|&o| p.graph.consts[o].as_ref())
.count();
println!("{label:>5} cold: source {src_us:8.1} µs | Tract facts {facts_us:8.1} µs | tile+fold+resolve {resolve_us:7.1} µs ({} steps, {} folded, {retained} folded constants)", p.steps.len(), p.folded.len());
println!("{label:>5} hot: graph plan {hot_ns:9.0} ns ({na} allocs, {nb} B) | Tract typed runnable {tract_ns:9.0} ns (incl. input tensors)");
}

Expand Down Expand Up @@ -152,6 +158,44 @@ fn main() {
20001,
);

// ── shape folding (D-TRACT-SHAPE-1) ──
let shape = std::fs::read(format!("{dir}/tiny/shape_fold.onnx")).unwrap();
measure(
"shape",
&shape,
&[],
vec![(
"F",
Value::F32(ArrayD::from_shape_vec(IxDyn(&[2, 3]), f.clone()).unwrap()),
)],
|| {
tvec![tract_ndarray::Array2::from_shape_vec((2, 3), f.clone())
.unwrap()
.into_dyn()
.into_tensor()
.into()]
},
20001,
);
let erf = std::fs::read(format!("{dir}/tiny/erf_sweep.onnx")).unwrap();
let ex: Vec<f32> = (0..4096).map(|i| -6.0 + 12.0 * i as f32 / 4095.0).collect();
measure(
"erf",
&erf,
&[(0, DatumType::F32, vec![ex.len()])],
vec![(
"X",
Value::F32(ArrayD::from_shape_vec(IxDyn(&[ex.len()]), ex.clone()).unwrap()),
)],
|| {
tvec![tract_ndarray::Array1::from(ex.clone())
.into_dyn()
.into_tensor()
.into()]
},
20001,
);

// ── fixture B ──
let slice = match std::fs::read(format!("{dir}/jina/slice.onnx")) {
Ok(b) => b,
Expand Down
105 changes: 105 additions & 0 deletions crates/tract-harvest-probe/examples/shape_census.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
//! Dependency census of the shape-plumbing ops, and what folds.
//!
//! `cargo run --release --example shape_census -- fixtures/jina/onnx_model.onnx [seq]`
//!
//! Without `seq` the inputs keep their symbolic dims; with it they are pinned
//! to `[1, seq]`.
use std::time::Instant;
use tract_harvest_probe::{dr3, fold, registry, source::SourceInventory};
use tract_onnx::prelude::*;

const OPS: &[&str] = &["Unsqueeze", "Shape", "Gather", "Concat", "Reshape"];

fn main() {
let path = std::env::args().nth(1).expect("model path");
let src = SourceInventory::from_bytes(&std::fs::read(&path).unwrap()).unwrap();
let pins: Vec<_> = std::env::args()
.nth(2)
.and_then(|s| s.parse::<usize>().ok())
.map(|seq| {
(0..src.inputs.len())
.map(|i| (i, i64::datum_type(), vec![1, seq]))
.collect()
})
.unwrap_or_default();
let t = Instant::now();
let fs = dr3::tract_fact_sources_path(std::path::Path::new(&path), &pins).unwrap();
let t_facts = t.elapsed();
let facts = fs.merged();
let mut rj = std::collections::BTreeMap::<String, usize>::new();
for r in &fs.rejected {
*rj.entry(format!(
"{}: {}",
r.stage,
r.reason
.split(" (")
.next()
.unwrap_or(&r.reason)
.chars()
.take(40)
.collect::<String>()
))
.or_default() += 1;
}
println!(
"admitted facts: inference {}, typed {}; rejected {:?}",
fs.inference.len(),
fs.typed.len(),
rj
);
let (_, node_dep) = fold::classify(&src, &facts);
println!(
"pins {:?}; typed stage error: {:?}",
pins.first().map(|p| &p.2),
fs.typed_error.as_deref().map(|e| &e[..e.len().min(80)])
);
println!("census [Const, Shape, Symbolic, Data]:");
for (op, c) in fold::census(&src, &node_dep, OPS) {
println!(" {op:>10} {c:?}");
}
let all = fold::census(
&src,
&node_dep,
&src.nodes
.iter()
.map(|n| n.op_type.as_str())
.collect::<Vec<_>>(),
);
let tot = all.values().fold([0; 4], |a, c| {
[a[0] + c[0], a[1] + c[1], a[2] + c[2], a[3] + c[3]]
});
println!(" all nodes {tot:?}");
let reg = registry::registry();
let t = Instant::now();
let f = fold::fold(&src, &facts, &reg);
let t_fold = t.elapsed();
let retained = f.retained(&src);
let bytes: usize = retained
.iter()
.map(|(_, v)| v.shape().iter().product::<usize>() * 8)
.sum();
println!(
"folded {} nodes in {t_fold:?} (facts {t_facts:?}); retained constants {} (~{bytes} B)",
f.nodes.len(),
retained.len()
);
let mut by = std::collections::BTreeMap::<String, usize>::new();
for (_, why) in &f.blocked {
*by.entry(why.clone()).or_default() += 1;
}
let cov = tract_harvest_probe::plan::coverage(&src, &facts, &reg);
let tot = cov
.values()
.fold([0; 3], |a, c| [a[0] + c[0], a[1] + c[1], a[2] + c[2]]);
println!(
"registry v{} coverage [WHEN true, WHEN not true, unregistered] {tot:?}",
reg.version
);
for op in OPS {
println!(" {op:>10} {:?}", cov.get(*op));
}
println!("foldable by dependency but not folded:");
for (why, c) in by {
println!(" {c:>5} {why}");
}
}
Binary file added crates/tract-harvest-probe/fixtures/tiny/erf.onnx
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file modified crates/tract-harvest-probe/fixtures/tiny/neg_unsupported.onnx
Binary file not shown.
Loading
Loading