Skip to content

Commit 6196a7f

Browse files
committed
test: make 0.31 cost acceptance reproducible and count static memory instructions
1 parent 65df937 commit 6196a7f

27 files changed

Lines changed: 4422 additions & 12 deletions

‎BRANCHES.md‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@ measurement, immediate planning, shared decoders, private adapter frames, and
99
scalar slot reuse. The implementation and remaining acceptance work are tracked
1010
in [the cost implementation report](docs/CELLSCRIPT_0_31_COST_IMPLEMENTATION.md).
1111
Cargo versions and the pinned toolchain remain unchanged during development.
12-
The requested branch layout makes `0.31` the GitHub default branch.
13-
14-
`0.30` is retained as the release baseline. Its complete local release gate
15-
passed on 2026-09-21; its signed tag points at that exact commit. Publication
16-
still requires verified release/deployment steps. Release tags and submodule
17-
repositories are preserved. The requested cleanup retires the other root
18-
branches after a verified local Git bundle backup. The descriptions below
19-
preserve historical evidence scope; consult the remote branch list for the
20-
actual completion state of that administrative operation.
12+
`0.31` became the GitHub default branch on 2026-09-24. The eight archived
13+
development branches were removed in one atomic push with exact-tip leases;
14+
the remote now retains `0.30` and `0.31`, together with all release tags.
15+
16+
`0.30` is retained as the published release baseline. Its final signed
17+
`v0.30.0` tag points at `352b4950e7dc7489a4ee7e7ed6d6edf4c7fefd71`.
18+
Release tags and submodule repositories are preserved. Retired root branch
19+
tips match the verified September 21 Git bundle. The descriptions below
20+
preserve historical evidence scope.
2121

2222
## 0.30
2323

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ accounting with frozen per-metric ceilings. See the
1313
[implementation and acceptance report](docs/CELLSCRIPT_0_31_COST_IMPLEMENTATION.md).
1414
These changes are development work; 0.31 has not been released.
1515

16+
Add decoded-ELF static load/store counts to scalar evidence, hash the shared
17+
measurement fixtures and frozen budgets, and provide a native before/after
18+
comparison command. A pinned patch and reproduction script rebuild the released
19+
0.30 backend with the expanded cost harness; the original dirty measurements
20+
remain historical evidence. See the [reproduction package](docs/reports/0.31/README.md).
21+
1622
## 0.30.0 - Capability closure
1723

1824
Publication and the website update were authorized on 2026-09-21, subject to a
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
//! Reproducible, per-metric comparison of two complete cost-report pairs.
2+
3+
use anyhow::{ensure, Context, Result};
4+
use serde_json::{json, Value};
5+
use std::{collections::BTreeMap, fs, path::Path};
6+
7+
fn read(path: &Path) -> Result<Value> {
8+
serde_json::from_slice(&fs::read(path)?).with_context(|| format!("parse {}", path.display()))
9+
}
10+
11+
fn named<'a>(value: &'a Value, field: &str) -> Result<BTreeMap<String, &'a Value>> {
12+
let mut rows = BTreeMap::new();
13+
for row in value[field].as_array().with_context(|| format!("missing {field}"))? {
14+
let name = row["name"].as_str().context("missing row name")?;
15+
ensure!(rows.insert(name.to_owned(), row).is_none(), "duplicate {field}/{name}");
16+
}
17+
Ok(rows)
18+
}
19+
20+
#[derive(Default)]
21+
struct Comparison {
22+
count: usize,
23+
regressions: Vec<Value>,
24+
scalar: Vec<Value>,
25+
}
26+
27+
impl Comparison {
28+
fn metric(&mut self, name: &str, field: &str, before: &Value, after: &Value, exact: bool) -> Result<()> {
29+
let left = before.as_u64().with_context(|| format!("missing baseline {name}/{field}"))?;
30+
let right = after.as_u64().with_context(|| format!("missing candidate {name}/{field}"))?;
31+
self.count += 1;
32+
if if exact { left != right } else { right > left } {
33+
self.regressions.push(json!({"name":name,"metric":field,"before":left,"after":right,"exact":exact}));
34+
}
35+
Ok(())
36+
}
37+
38+
fn observations(&mut self, name: &str, before: &Value, after: &Value) -> Result<()> {
39+
ensure!(before["scope"] == after["scope"], "{name}: measurement scope changed");
40+
ensure!(before["exit_category"] == after["exit_category"], "{name}: exit category changed");
41+
let left = &before["observation"];
42+
let right = &after["observation"];
43+
ensure!(left["status"] == right["status"] && left["exit_code"] == right["exit_code"], "{name}: outcome changed");
44+
if left["status"] == "measured" {
45+
self.metric(name, "cycles", &left["cycles"], &right["cycles"], false)?;
46+
}
47+
Ok(())
48+
}
49+
50+
fn corpus(&mut self, before: &Value, after: &Value) -> Result<()> {
51+
for family in ["matched", "growth", "expanded", "scalar", "executions"] {
52+
let left = named(before, family)?;
53+
let right = named(after, family)?;
54+
ensure!(left.keys().eq(right.keys()), "{family}: fixture set changed");
55+
for (name, row) in left {
56+
let candidate = right[&name];
57+
ensure!(row["source_sha256"] == candidate["source_sha256"], "{name}: fixture source changed");
58+
for field in [
59+
"elf_bytes",
60+
"positive_cycles",
61+
"group_positive_cycles",
62+
"group_rejection_cycles",
63+
"max_stack_frame_bytes",
64+
"static_call_chain_stack_bound_bytes",
65+
"witness_bytes",
66+
"transaction_bytes",
67+
] {
68+
if !row[field].is_null() || !candidate[field].is_null() {
69+
self.metric(
70+
&name,
71+
field,
72+
&row[field],
73+
&candidate[field],
74+
matches!(field, "witness_bytes" | "transaction_bytes"),
75+
)?;
76+
}
77+
}
78+
if family == "matched" {
79+
for field in ["rust_elf_bytes", "rust_positive_cycles", "elf_budget", "cycle_budget"] {
80+
ensure!(row[field] == candidate[field], "{name}: Rust reference or budget changed: {field}");
81+
}
82+
}
83+
if family == "growth" {
84+
self.metric(
85+
&name,
86+
"static_stack",
87+
&row["static_stack"]["static_call_chain_stack_bound_bytes"],
88+
&candidate["static_stack"]["static_call_chain_stack_bound_bytes"],
89+
false,
90+
)?;
91+
}
92+
if family == "scalar" {
93+
super::cost_evidence::static_memory(&row["static_memory"])?;
94+
super::cost_evidence::static_memory(&candidate["static_memory"])?;
95+
self.scalar.push(json!({"name":name,"source_sha256":row["source_sha256"],
96+
"before_elf_sha256":row["elf_sha256"],"after_elf_sha256":candidate["elf_sha256"],
97+
"before":row["static_memory"],"after":candidate["static_memory"]}));
98+
}
99+
if family == "executions" {
100+
if name.split('/').nth(1) == Some("rust") {
101+
ensure!(row["elf_sha256"] == candidate["elf_sha256"], "{name}: Rust reference ELF changed");
102+
}
103+
ensure!(row["oracle_exit_code"] == candidate["oracle_exit_code"], "{name}: oracle changed");
104+
for scope in ["transaction", "group"] {
105+
self.observations(&format!("{name}/{scope}"), &row[scope], &candidate[scope])?;
106+
}
107+
for field in ["role", "input_indices", "output_indices"] {
108+
ensure!(row["group"]["group"][field] == candidate["group"]["group"][field], "{name}: group selection changed");
109+
}
110+
}
111+
}
112+
}
113+
Ok(())
114+
}
115+
116+
fn multi(&mut self, before: &Value, after: &Value) -> Result<()> {
117+
let left = named(before, "rows")?;
118+
let right = named(after, "rows")?;
119+
ensure!(left.keys().eq(right.keys()), "multi-Script fixture set changed");
120+
for (name, row) in left {
121+
let candidate = right[&name];
122+
for field in ["elf_bytes", "max_stack_frame_bytes", "transaction_bytes", "witness_bytes"] {
123+
self.metric(&name, field, &row[field], &candidate[field], matches!(field, "transaction_bytes" | "witness_bytes"))?;
124+
}
125+
self.observations(&name, &row["transaction"], &candidate["transaction"])?;
126+
let group_key = |group: &Value| {
127+
json!([group["group"]["role"], group["group"]["input_indices"], group["group"]["output_indices"]]).to_string()
128+
};
129+
let groups = |value: &Value| -> Result<BTreeMap<String, Value>> {
130+
let mut groups = BTreeMap::new();
131+
for group in value["groups"].as_array().context("missing groups")? {
132+
ensure!(groups.insert(group_key(group), group.clone()).is_none(), "duplicate group");
133+
}
134+
Ok(groups)
135+
};
136+
let left_groups = groups(row)?;
137+
let right_groups = groups(candidate)?;
138+
ensure!(left_groups.keys().eq(right_groups.keys()), "{name}: group membership changed");
139+
for (key, group) in left_groups {
140+
self.observations(&format!("{name}/{key}"), &group, &right_groups[&key])?;
141+
}
142+
let stacks = named(row, "static_stacks")?;
143+
let candidate_stacks = named(candidate, "static_stacks")?;
144+
ensure!(stacks.keys().eq(candidate_stacks.keys()), "{name}: static stack set changed");
145+
for (key, stack) in stacks {
146+
self.metric(
147+
&format!("{name}/{key}"),
148+
"static_stack",
149+
&stack["bound"]["static_call_chain_stack_bound_bytes"],
150+
&candidate_stacks[&key]["bound"]["static_call_chain_stack_bound_bytes"],
151+
false,
152+
)?;
153+
}
154+
}
155+
Ok(())
156+
}
157+
}
158+
159+
pub fn run(root: &Path, before: &Path, before_multi: &Path, after: &Path, after_multi: &Path, output: &Path) -> Result<()> {
160+
// A failed comparison must not leave an earlier passing result in place.
161+
fs::write(output, b"{\"status\":\"not-generated\"}\n")?;
162+
super::cost_evidence::check(root, before, before_multi)?;
163+
super::cost_evidence::check(root, after, after_multi)?;
164+
let baseline = read(before)?;
165+
let candidate = read(after)?;
166+
for field in [
167+
"cargo_lock_sha256",
168+
"rust_reference_lock_sha256",
169+
"rust_toolchain",
170+
"strip_sha256",
171+
"strip_version",
172+
"target",
173+
"target_profile",
174+
"edition",
175+
"opt_level",
176+
"rust_reference_profile",
177+
"vm_configuration",
178+
"measurement_fixture_sha256",
179+
] {
180+
ensure!(baseline["provenance"][field] == candidate["provenance"][field], "comparison configuration changed: {field}");
181+
}
182+
let mut comparison = Comparison::default();
183+
comparison.corpus(&baseline, &candidate)?;
184+
comparison.multi(&read(before_multi)?, &read(after_multi)?)?;
185+
let report = json!({"schema":"cellscript-cost-comparison-v2","baseline":baseline["provenance"],
186+
"candidate":candidate["provenance"],"comparisons":comparison.count,"regressions":comparison.regressions,
187+
"scalar_static_memory":comparison.scalar,"status":if comparison.regressions.is_empty(){"passed"}else{"failed"}});
188+
fs::write(output, serde_json::to_vec_pretty(&report)?)?;
189+
ensure!(comparison.regressions.is_empty(), "cost regressions: see {}", output.display());
190+
println!("{} comparable metrics passed; static memory counts reported separately", comparison.count);
191+
Ok(())
192+
}
193+
194+
#[cfg(test)]
195+
mod tests {
196+
use super::*;
197+
198+
#[test]
199+
fn comparison_rejects_missing_duplicate_or_changed_measurements() {
200+
assert!(named(&json!({"rows":[{"name":"a"},{"name":"a"}]}), "rows").is_err());
201+
let mut comparison = Comparison::default();
202+
assert!(comparison.metric("a", "cycles", &json!(8), &Value::Null, false).is_err());
203+
comparison.metric("a", "cycles", &json!(8), &json!(9), false).unwrap();
204+
comparison.metric("a", "witness", &json!(8), &json!(7), true).unwrap();
205+
assert_eq!(comparison.regressions.len(), 2);
206+
let measured = json!({"scope":"transaction_scripts","exit_category":"success","observation":{"status":"measured","exit_code":0,"cycles":8}});
207+
let mut changed = measured.clone();
208+
changed["observation"]["status"] = json!("unavailable");
209+
assert!(comparison.observations("a", &measured, &changed).is_err());
210+
}
211+
}

‎crates/cellscript-tools/src/cost_evidence.rs‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ fn same_source(left: &Value, right: &Value) -> Result<()> {
9393
"cargo_lock_sha256",
9494
"rust_toolchain",
9595
"vm_configuration",
96+
"measurement_fixture_sha256",
9697
] {
9798
ensure!(!left[field].is_null() && left[field] == right[field], "cost report source/configuration mismatch: {field}");
9899
}
@@ -107,6 +108,16 @@ fn same_source(left: &Value, right: &Value) -> Result<()> {
107108
Ok(())
108109
}
109110

111+
pub(crate) fn static_memory(value: &Value) -> Result<()> {
112+
ensure!(value["schema"] == "cellscript-static-memory-counts-v1", "missing static memory count schema");
113+
ensure!(value["scope"] == "decoded_elf_text_including_unreachable", "wrong static memory count scope");
114+
let instructions = positive(&value["instruction_count"])?;
115+
let loads = value["load_instruction_count"].as_u64().context("missing static load count")?;
116+
let stores = value["store_instruction_count"].as_u64().context("missing static store count")?;
117+
ensure!(loads.checked_add(stores).is_some_and(|total| total <= instructions), "impossible static memory counts");
118+
Ok(())
119+
}
120+
110121
pub fn check(root: &Path, path: &Path, multi_path: &Path) -> Result<()> {
111122
let report = read(path)?;
112123
ensure!(report["schema"] == "cellscript-cost-corpus-v2" && report["status"] == "passed", "missing passing cost-report v2");
@@ -139,8 +150,17 @@ pub fn check(root: &Path, path: &Path, multi_path: &Path) -> Result<()> {
139150
ensure!(!report["provenance"][field].as_str().unwrap_or("").is_empty(), "missing provenance {field}");
140151
}
141152
ensure!(report["provenance"]["source_dirty"].is_boolean(), "missing dirty-state declaration");
153+
let fixtures = report["provenance"]["measurement_fixture_sha256"].as_object().context("missing measurement fixture hashes")?;
154+
ensure!(fixtures.len() == 24, "measurement fixture inventory incomplete");
155+
for digest in fixtures.values() {
156+
let digest = digest.as_str().context("fixture digest")?;
157+
ensure!(digest.len() == 64 && hex::decode(digest).is_ok(), "invalid fixture digest");
158+
}
142159
rows_with_budgets(root, &report, "expanded", "expanded_budgets.json")?;
143160
rows_with_budgets(root, &report, "scalar", "scalar_budgets.json")?;
161+
for row in array(&report, "scalar")? {
162+
static_memory(&row["static_memory"])?;
163+
}
144164
let mut names = BTreeSet::new();
145165
let executions = array(&report, "executions")?;
146166
ensure!(executions.len() >= 1800, "execution sweep incomplete");
@@ -212,7 +232,7 @@ mod tests {
212232
"compiler_version":"0.30.0", "source_commit":"commit",
213233
"source_dirty":true, "tracked_diff_sha256":"tracked",
214234
"untracked_source_sha256":"untracked", "cargo_lock_sha256":"lock",
215-
"rust_toolchain":"rustc", "vm_configuration":{"limit":10_000_000},
235+
"rust_toolchain":"rustc", "vm_configuration":{"limit":10_000_000}, "measurement_fixture_sha256":{"fixture":"hash"},
216236
"edition":"2027", "opt_level":3, "target":"riscv64-elf", "target_profile":"ckb"
217237
});
218238
let mut companion = source.clone();
@@ -228,6 +248,7 @@ mod tests {
228248
"cargo_lock_sha256",
229249
"rust_toolchain",
230250
"vm_configuration",
251+
"measurement_fixture_sha256",
231252
] {
232253
let mut changed = companion.clone();
233254
changed[field] = json!("different");
@@ -258,4 +279,20 @@ mod tests {
258279
value["exit_category"] = json!("vm_trap");
259280
assert!(measurement(&value, false).is_err());
260281
}
282+
#[test]
283+
fn static_memory_requires_explicit_scope_and_consistent_counts() {
284+
let value = json!({"schema":"cellscript-static-memory-counts-v1", "scope":"decoded_elf_text_including_unreachable",
285+
"instruction_count":10, "load_instruction_count":0, "store_instruction_count":0});
286+
static_memory(&value).unwrap();
287+
for (field, invalid) in [
288+
("scope", json!("executed")),
289+
("load_instruction_count", json!(11)),
290+
("store_instruction_count", Value::Null),
291+
("instruction_count", json!(0)),
292+
] {
293+
let mut changed = value.clone();
294+
changed[field] = invalid;
295+
assert!(static_memory(&changed).is_err(), "accepted invalid {field}");
296+
}
297+
}
261298
}

‎crates/cellscript-tools/src/main.rs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ mod ckb_acceptance;
1717
mod ckb_acceptance_live;
1818
mod ckb_adapter_live;
1919
mod ckb_devnet;
20+
mod cost_comparison;
2021
mod cost_evidence;
2122
mod crypto;
2223
mod evidence_retention;
@@ -160,6 +161,15 @@ enum Command {
160161
CheckDocStatus,
161162
/// Validate versioned cost measurements and the frozen per-fixture ceilings.
162163
CheckCostEvidence { report: PathBuf, multi_script_report: PathBuf },
164+
/// Compare complete baseline/candidate report pairs without changing budgets.
165+
CompareCostEvidence {
166+
baseline: PathBuf,
167+
baseline_multi: PathBuf,
168+
candidate: PathBuf,
169+
candidate_multi: PathBuf,
170+
#[arg(long)]
171+
output: PathBuf,
172+
},
163173
/// Validate the frozen 0.30 business-capability corpus inventory.
164174
CheckBusinessCorpus {
165175
/// Refresh the sorted evidence inventory and its content digest.
@@ -449,6 +459,12 @@ fn main() -> ExitCode {
449459
Ok(()) => ExitCode::SUCCESS,
450460
Err(error) => failure(error),
451461
},
462+
Command::CompareCostEvidence { baseline, baseline_multi, candidate, candidate_multi, output } => {
463+
match cost_comparison::run(&root, &baseline, &baseline_multi, &candidate, &candidate_multi, &output) {
464+
Ok(()) => ExitCode::SUCCESS,
465+
Err(error) => failure(error),
466+
}
467+
}
452468
Command::CheckBusinessCorpus { write, release } => match business_corpus::run(&root, write, release) {
453469
Ok(()) => ExitCode::SUCCESS,
454470
Err(error) => failure(error),

‎docs/CELLSCRIPT_0_31_COST_DECISIONS.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ metrics. Child issues remain open until their implementation acceptance passes.
236236

237237
The 0.32 follow-ups above are deferrals, not shipped capabilities or promises of
238238
0.32 completion. New implementation scope requires a new issue and decision.
239+
They now have separate trackers: [observed per-VM stack use (#36)](https://github.com/CellScript-Labs/CellScript/issues/36),
240+
[dispatch alternatives (#37)](https://github.com/CellScript-Labs/CellScript/issues/37),
241+
[borrowed spans and copy changes (#38)](https://github.com/CellScript-Labs/CellScript/issues/38),
242+
and [register retention and helper scratch (#39)](https://github.com/CellScript-Labs/CellScript/issues/39).
239243
The numeric budgets for new fixtures will be accepted from measured baseline
240244
results; choosing them now would invent evidence.
241245

‎docs/CELLSCRIPT_0_31_COST_IMPLEMENTATION.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ that every transaction's first error is identical after recompilation.
108108

109109
## Compatibility and remaining acceptance
110110

111+
The [reproduction package](reports/0.31/README.md) supplies a fixed source patch
112+
for replaying the released 0.30 backend with the expanded measurement harness,
113+
static scalar load/store counts and a native report comparator. The original
114+
dirty checkpoint reports below remain historical records. Deferred experiments
115+
are tracked separately in #36–#39 and do not enlarge this release's scope.
116+
111117
Native CLI builds emit v9 records. The standalone checker, the CKB adapter,
112118
the Registry verifier and the Registry artifact verifier consume the same
113119
independent checker crate. Their builds and tests must move with this change.

0 commit comments

Comments
 (0)