Skip to content

Commit f5faa95

Browse files
committed
feat(L10): rung-3b runtime — per-variable delta residue replay
Refine `reverse <name>` to restore only the state fields its `reversible as <name>` body mutated (a before-image delta), not the whole agent state map. Fixes a real clobber: under the whole-checkpoint cut a reverse would silently revert an unrelated field that an intervening handler had changed. - eval: `collect_set_fields` statically scans the body (descending nested control) for `set` targets; `ReversibleAs` captures the before-image of just those fields as `Holding(delta)`; `ReverseNamed` writes them back rather than replacing the whole `AgentInstance.state`. Over-approximation is harmless (a field set only on an untaken branch is captured at its current value, so write-back is a no-op). - test: residue_reverse_is_per_field_delta_not_whole_checkpoint — a reverse restores `balance` but preserves a `note` an intervening handler changed. - spec §11b.8 / example / design-note: "checkpoint" -> "per-variable delta" in lockstep. 914 oo7-core lib tests pass; fmt + clippy clean; example + e2e green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018CaSgNjNURC7ocsyjYh9We
1 parent 12804f8 commit f5faa95

6 files changed

Lines changed: 227 additions & 43 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ All notable changes to this project will be documented in this file.
1212
- **Layer 10 rung 3b (static half) — cross-handler named residues.** A `reversible as <name>` in one handler is now reversible by `reverse <name>` in *another* handler of the same agent — the residue is carried through agent state. The per-handler isolation that previously severed this is lifted to a per-agent `agent_residue_names` set collected up front; a cross-handler `reverse <name>` type-checks by **presence** (some handler declares it). The discipline is honestly split: within one handler the rung-3a *linear* discipline still holds (in-body double-`reverse` rejected), but cross-handler *once-only* is a **runtime** property — handler firing order is not statically known (`takeForReverse : Maybe`), so it is deferred to the runtime half. `take_named_residue` now returns a 3-way `ResidueTake` (`Took`/`AlreadySpent`/`NotPresent`) so the in-body double-reverse (`AlreadySpent`) is not masked by agent-level presence. Cross-*function* carry stays rejected (functions don't share state). 3 new typechecker tests + new `examples/cross_handler_reversibility.007` (parse + typecheck + run all pass). The runtime replay (evaluator executing the reversal log) is the remaining follow-on.
1313
- **Mutable agent state — `set <field> = <expr>`.** The runtime substrate the rung-3b residue cell needs: agent handlers previously received a *copy* of agent state with no write-back, so state was effectively immutable and there was no assignment statement at all. The new `set <field> = <expr>` control statement assigns to a declared `state` field and **persists** the value into the live `AgentInstance.state`, so a later handler firing observes it (proven by two evaluator tests: within-agent `set count = 7` persistence, and `set count = count + 1` accumulating across three `receive` firings observed by a *separate* `report` handler). Static discipline: the target must be a declared `state` of the enclosing agent — assigning a `let`/param/undeclared name is the new `SET_NON_STATE` error (`TypeErrorKind::AssignToNonState`, surfaced to agents with `declare_state`/`use_let` remediations) — and the value's type must match the field's declared type (else an L1 `TypeMismatch`). Additive AST variant `SetState { field, value }` (existing variants untouched); grammar (`grammar.pest` + `spec/grammar.ebnf` `set_stmt`, new `set` keyword), parser, eval (write into `AgentInstance.state` + `Env::update` for in-handler reads), typechecker (`agent_state_fields` set, mirroring rung-3b's `agent_residue_names`), and the codegen ×3 / dual-ast / formatter / metainterpreter / CFG / oracle pipeline all threaded. 1 parser + 3 typechecker + 2 evaluator tests (910 `oo7-core` lib tests passing; fmt + clippy clean) + new `examples/mutable_agent_state.007` (parse + typecheck + run all pass). Spec: TYPE-SYSTEM-SPEC §11b.7.
1414
- **Layer 10 rung 3b (runtime half) — echo-residue cells + runtime replay.** `reverse <name>` now has an operational meaning: restore the agent to the checkpoint captured by the matching `reversible as <name>`. New explicit `residue <name>` agent-member declaration (the chosen *explicit state cell* design) declares a residue cell; `AgentInstance` carries a `residues: HashMap<String, ResidueCell>` where `ResidueCell = Holding(state-snapshot) | Spent` (the evaluator shadow of the Idris `ResidueCell`). Evaluator semantics: `reversible as <name>` snapshots the agent state map *before* its body runs (`Holding`), so a later — possibly cross-handler — `reverse <name>` restores that snapshot and marks the cell `Spent`; a second `reverse <name>` finds `Spent`/absent and is a no-op (the runtime witness of `takeForReverse : … → Maybe` = `Nothing`). The cell lives on the instance, so it persists across handler firings, closing the cross-handler once-only that the static half (#50) could only check by presence. Built on the §11b.7 mutable-state substrate; *checkpoint* semantics (restore the whole state map, not a per-variable delta) is the honest scope. Additive `residue_decl` grammar (`grammar.pest` + `spec/grammar.ebnf`, new `residue` keyword) + `AgentDecl.residues` field; declared residues also feed the rung-3b static presence-set. Evaluator tests prove an actual cross-handler rollback (debit then `reverse undo` restores the balance) and the spent-is-no-op discipline. New `examples/transactional_rollback.007`. Spec: TYPE-SYSTEM-SPEC §11b.8.
15+
- **Layer 10 rung 3b runtime — residue replay is a per-variable delta, not a whole-state checkpoint.** Refines the runtime residue cell so `reverse <name>` restores *only* the state fields its `reversible as <name>` body mutated, leaving everything else — including unrelated `set`s made by *intervening* handlers — intact. `reversible as` now captures a **before-image delta** (the prior values of exactly the `set` fields, found by a static `collect_set_fields` scan that descends through nested control), and `reverse` writes just those back, rather than cloning/restoring the whole `AgentInstance.state` map. This fixes a real clobber: under the previous whole-checkpoint cut, a `reverse` would silently revert an unrelated field that a later handler had changed. Over-approximating the field set is harmless (a field `set` only on an untaken branch is captured at its current value, so write-back is a no-op). New evaluator test `residue_reverse_is_per_field_delta_not_whole_checkpoint` (debit arms `undo` on `balance`; a separate handler changes `note`; `reverse undo` restores `balance` but preserves `note`) — 914 `oo7-core` lib tests passing, fmt + clippy clean. Spec/example/design-note updated from "checkpoint" to "per-variable delta" wording in lockstep. Spec: TYPE-SYSTEM-SPEC §11b.8.
1516
- **Layer 10 proofs gated in the canonical proof suite.** The L10 echo-types proofs are now first-class suite entries (`audits/canonical-proof-suite/MANIFEST.a2ml`), gated nightly like the M/S/E classics: new `language` domain `L1` (`proofs/idris2/EchoResidue.idr`, headline `reverseAfterIrreversibleIllTyped`) and `L2` (`proofs/idris2/EchoResidueLinear.idr`, headline `reverseLinear`) — 37 entries total. Each gets the runner's banned-construct scan + `idris2 --check` + symbol-defined check; both verified locally (exit 0, zero banned constructs). The two files' "zero believe_me/assert_total/sorry" disclaimer comments were reworded so the substring banned-scan doesn't false-positive on the very tokens they disclaim. New end-to-end example `examples/named_reversibility.007` exercises `reversible as`/`reverse <name>` through the full pipeline (parse + typecheck + run all pass).
1617
- **`just gate` — one-command, billing-independent local green-check.** Runs every locally-faithful CI stage in sequence (rustfmt, clippy, `cargo test`, grammar-check, verify-harvard, e2e, `idris2 --check` of all 12 Idris proofs), continues past failures, and prints a per-stage PASS/FAIL/SKIP summary with a non-zero exit on any FAIL. Stages needing an absent toolchain (idris2) or an external GitHub action (A2ML/K9 validators, security scans) self-skip with a notice rather than failing, so it runs anywhere; the rocq canonical-proof-suite (needs Rocq 9) and external manifest/security scans stay CI/nightly-only. Verified GREEN on `main`. Distinct from `just ci` (build/test/lint/verify simulation) and `just check` (fast fmt/lint/test pre-commit).
1718
- **Import binding-collision detection.** The type checker now validates `import` declarations: every binding an import introduces — an `as` alias, each selective `from … import` item, or a bare `import a.b.c`'s last segment (`c`) — must be unique across the program. A collision (e.g. `import a as x` + `import b as x`, or `from m import abs, abs`) is now a `DuplicateImport` error instead of being silently accepted (imports were previously skipped by the gather pass entirely). Surfaced to agents via `agent_api` code `IMPORT_DUPLICATE_BINDING` (+`rename_import`/`remove_duplicate` remediations). Complements the existing `import_resolver` (which only detects *circular* imports). 4 new typechecker tests (894 `oo7-core` lib tests passing); `examples/imports.007` and the other import-bearing examples are unaffected.

crates/oo7-core/src/eval.rs

Lines changed: 88 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ use crate::ast::*;
2323
use crate::trace::{DecisionTrace, TraceRecord};
2424
use chrono::Utc;
2525
use serde_json::Value as JsonValue;
26-
use std::collections::{HashMap, VecDeque};
26+
use std::collections::{HashMap, HashSet, VecDeque};
2727

2828
/// Safety limit for loops without break — prevents infinite loops
2929
/// in the reference evaluator. Production runtimes would use fuel/budgets.
@@ -553,13 +553,73 @@ impl BranchStrategy for ConsensusStrategy {
553553
/// handler firings (the cross-handler rung-3b path).
554554
#[derive(Debug, Clone)]
555555
pub enum ResidueCell {
556-
/// A live reversal checkpoint: the agent state captured immediately before
557-
/// the `reversible as <name>` body ran. `reverse <name>` restores it.
556+
/// A live reversal *delta*: the before-image of exactly the state fields the
557+
/// `reversible as <name>` body mutates, captured immediately before it ran.
558+
/// `reverse <name>` writes each captured field back, leaving every other
559+
/// field — including unrelated `set`s from intervening handlers — untouched.
558560
Holding(HashMap<String, RtValue>),
559561
/// The residue has already been replayed (consumed exactly once).
560562
Spent,
561563
}
562564

565+
/// Collect the names of agent `state` fields that a control body assigns via
566+
/// `set <field> = …`, descending through every nested control construct. This
567+
/// is the static support of a `reversible as` body's before-image delta: the
568+
/// evaluator snapshots exactly these fields before the body runs. Over-approx
569+
/// is harmless — a field that is `set` only on an untaken branch is captured at
570+
/// its current value, so restoring it is a no-op. (Mirrors the recursion shape
571+
/// of the type checker's `collect_reversible_as_bindings`.)
572+
fn collect_set_fields(body: &[ControlStmt]) -> HashSet<String> {
573+
fn go(stmt: &ControlStmt, out: &mut HashSet<String>) {
574+
match stmt {
575+
ControlStmt::SetState { field, .. } => {
576+
out.insert(field.clone());
577+
}
578+
ControlStmt::Reversible(b)
579+
| ControlStmt::Irreversible(b)
580+
| ControlStmt::Reverse(b)
581+
| ControlStmt::ReversibleAs { body: b, .. }
582+
| ControlStmt::Loop { body: b, .. }
583+
| ControlStmt::EnterDiscourse { body: b, .. } => {
584+
for s in b {
585+
go(s, out);
586+
}
587+
}
588+
ControlStmt::If {
589+
then_body,
590+
else_body,
591+
..
592+
} => {
593+
for s in then_body.iter().chain(else_body) {
594+
go(s, out);
595+
}
596+
}
597+
ControlStmt::Branch { arms, .. } => {
598+
for arm in arms {
599+
for s in &arm.body {
600+
go(s, out);
601+
}
602+
}
603+
}
604+
ControlStmt::Match { arms, .. } => {
605+
for (_pat, body) in arms {
606+
if let ControlExprOrBlock::Block(stmts, _) = body {
607+
for s in stmts {
608+
go(s, out);
609+
}
610+
}
611+
}
612+
}
613+
_ => {}
614+
}
615+
}
616+
let mut out = HashSet::new();
617+
for s in body {
618+
go(s, &mut out);
619+
}
620+
out
621+
}
622+
563623
/// A live agent instance in the runtime. Each spawned agent gets one.
564624
///
565625
/// Agents are cooperative (single-threaded): message delivery is
@@ -1958,18 +2018,26 @@ impl Evaluator {
19582018
r
19592019
}
19602020
ControlStmt::ReversibleAs { binding, body } => {
1961-
// L10 rung-3b runtime: snapshot the agent's state *before* the
1962-
// body runs, store it as a `Holding` residue cell keyed by
1963-
// `binding`, then run the body. A later `reverse <binding>`
1964-
// restores this checkpoint. The cell lives on the agent
1965-
// instance, so the snapshot survives into other handlers.
2021+
// L10 rung-3b runtime: capture a *before-image delta* of the
2022+
// fields this body mutates, store it as a `Holding` residue cell
2023+
// keyed by `binding`, then run the body. A later `reverse
2024+
// <binding>` restores exactly those fields. Snapshotting only the
2025+
// touched fields (not the whole state map) means a `reverse`
2026+
// leaves state that the body never wrote untouched — including
2027+
// unrelated `set`s made by intervening handlers. The cell lives
2028+
// on the agent instance, so the delta survives into other
2029+
// handlers.
2030+
let touched = collect_set_fields(body);
19662031
if let Some(agent_id) = self.current_agent_id.clone()
19672032
&& let Some(agent) = self.agents.get_mut(&agent_id)
19682033
{
1969-
let snapshot = agent.state.clone();
2034+
let before_image: HashMap<String, RtValue> = touched
2035+
.iter()
2036+
.filter_map(|f| agent.state.get(f).map(|v| (f.clone(), v.clone())))
2037+
.collect();
19702038
agent
19712039
.residues
1972-
.insert(binding.clone(), ResidueCell::Holding(snapshot));
2040+
.insert(binding.clone(), ResidueCell::Holding(before_image));
19732041
}
19742042
env.push_scope();
19752043
let r = self.eval_stmts(body, env);
@@ -1978,15 +2046,19 @@ impl Evaluator {
19782046
}
19792047
ControlStmt::ReverseNamed { target } => {
19802048
// L10 rung-3b runtime: replay the named residue. If its cell is
1981-
// `Holding`, restore the snapshotted agent state and mark the
1982-
// cell `Spent` (consumed once). If `Spent` or absent, this is a
1983-
// no-op — the runtime witness of `takeForReverse` = `Nothing`.
2049+
// `Holding`, restore each field in the before-image delta to its
2050+
// captured value and mark the cell `Spent` (consumed once). If
2051+
// `Spent` or absent, this is a no-op — the runtime witness of
2052+
// `takeForReverse` = `Nothing`. Only the recorded fields are
2053+
// written back; all other state is left as-is.
19842054
if let Some(agent_id) = self.current_agent_id.clone()
19852055
&& let Some(agent) = self.agents.get_mut(&agent_id)
1986-
&& let Some(ResidueCell::Holding(snapshot)) = agent.residues.get(target)
2056+
&& let Some(ResidueCell::Holding(before_image)) = agent.residues.get(target)
19872057
{
1988-
let snapshot = snapshot.clone();
1989-
agent.state = snapshot;
2058+
let before_image = before_image.clone();
2059+
for (field, value) in before_image {
2060+
agent.state.insert(field, value);
2061+
}
19902062
agent.residues.insert(target.clone(), ResidueCell::Spent);
19912063
}
19922064
RtValue::Unit

crates/oo7-core/src/eval_tests.rs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1324,6 +1324,108 @@ mod tests {
13241324
);
13251325
}
13261326

1327+
#[test]
1328+
fn residue_reverse_is_per_field_delta_not_whole_checkpoint() {
1329+
// The residue captures a *before-image delta* of only the fields its
1330+
// body mutates, so `reverse` leaves unrelated state alone. Here `debit`
1331+
// arms `undo` touching only `balance`; a separate `annotate` handler
1332+
// then changes the unrelated field `note`; `reverse undo` restores
1333+
// `balance` but MUST preserve `note`. A whole-state checkpoint would
1334+
// have clobbered `note` back to its pre-debit value — this test pins
1335+
// the delta semantics that fixes that.
1336+
let mut evaluator = Evaluator::new();
1337+
let agent_decl = AgentDecl {
1338+
name: "Ledger".to_string(),
1339+
capabilities: vec![],
1340+
budget: None,
1341+
implements: vec![],
1342+
locale: None,
1343+
data_blocks: vec![],
1344+
handlers: vec![
1345+
// debit: arm `undo`, touching ONLY `balance`.
1346+
Handler {
1347+
event: "debit".to_string(),
1348+
params: vec![("amount".to_string(), "Int".to_string())],
1349+
body: vec![ControlStmt::ReversibleAs {
1350+
binding: "undo".to_string(),
1351+
body: vec![ControlStmt::SetState {
1352+
field: "balance".to_string(),
1353+
value: ControlExpr::BinOp(
1354+
Box::new(ControlExpr::Var("balance".to_string())),
1355+
BinOp::Sub,
1356+
Box::new(ControlExpr::Var("amount".to_string())),
1357+
),
1358+
}],
1359+
}],
1360+
},
1361+
// annotate: change the UNRELATED field `note`, no residue.
1362+
Handler {
1363+
event: "annotate".to_string(),
1364+
params: vec![("ignored".to_string(), "Int".to_string())],
1365+
body: vec![ControlStmt::SetState {
1366+
field: "note".to_string(),
1367+
value: ControlExpr::DataLit(DataExpr::Int(7)),
1368+
}],
1369+
},
1370+
// rollback: replay the `undo` delta.
1371+
Handler {
1372+
event: "rollback".to_string(),
1373+
params: vec![("ignored".to_string(), "Int".to_string())],
1374+
body: vec![ControlStmt::ReverseNamed {
1375+
target: "undo".to_string(),
1376+
}],
1377+
},
1378+
],
1379+
states: vec![
1380+
("balance".to_string(), DataExpr::Int(100)),
1381+
("note".to_string(), DataExpr::Int(0)),
1382+
],
1383+
residues: vec!["undo".to_string()],
1384+
};
1385+
evaluator.register_agent_decl(agent_decl);
1386+
1387+
let mut env = Env::new();
1388+
evaluator.eval_stmt(
1389+
&ControlStmt::Spawn {
1390+
attested: false,
1391+
linear: false,
1392+
binding: "h".to_string(),
1393+
agent_name: "Ledger".to_string(),
1394+
caps: vec![],
1395+
args: vec![],
1396+
},
1397+
&mut env,
1398+
);
1399+
let agent_id = match env.get("h") {
1400+
Some(RtValue::Handle(id)) => id.clone(),
1401+
_ => panic!("Expected handle"),
1402+
};
1403+
let field = |e: &Evaluator, k: &str| e.agents.get(&agent_id).unwrap().state.get(k).cloned();
1404+
1405+
// Debit 30: balance 100 -> 70; delta {balance:100} held. note untouched.
1406+
evaluator.run_agent_handler(&agent_id, "debit", &RtValue::Int(30));
1407+
assert_eq!(field(&evaluator, "balance"), Some(RtValue::Int(70)));
1408+
assert_eq!(field(&evaluator, "note"), Some(RtValue::Int(0)));
1409+
1410+
// Annotate AFTER the checkpoint: note 0 -> 7 (unrelated to `undo`).
1411+
evaluator.run_agent_handler(&agent_id, "annotate", &RtValue::Int(0));
1412+
assert_eq!(field(&evaluator, "note"), Some(RtValue::Int(7)));
1413+
1414+
// Reverse: balance restored to 100, but note STAYS 7 (delta, not
1415+
// whole-checkpoint — the unrelated intervening write survives).
1416+
evaluator.run_agent_handler(&agent_id, "rollback", &RtValue::Int(0));
1417+
assert_eq!(
1418+
field(&evaluator, "balance"),
1419+
Some(RtValue::Int(100)),
1420+
"reverse must restore the field the body mutated"
1421+
);
1422+
assert_eq!(
1423+
field(&evaluator, "note"),
1424+
Some(RtValue::Int(7)),
1425+
"reverse must NOT clobber a field the body never touched"
1426+
);
1427+
}
1428+
13271429
// ================================================================
13281430
// SECTION 20: AGENT ERGONOMICS — query_trace
13291431
// ================================================================

0 commit comments

Comments
 (0)