diff --git a/AGENTS.md b/AGENTS.md index aa764fd..2ff163f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,8 @@ code: - [`workspace-and-ledgers.md`](docs/specs/workspace-and-ledgers.md) — what a run writes down and where - [`budget.md`](docs/specs/budget.md) — the limits every run carries +- [`adaptation.md`](docs/specs/adaptation.md) — what a run may revise about + itself mid-run, who may propose it, and what bounds it - [`observability.md`](docs/specs/observability.md) — the events a run emits - [`prior-art.md`](docs/specs/prior-art.md) — the evidence each default rests on, what the record contradicts, and what it does not settle; its sources are diff --git a/Cargo.toml b/Cargo.toml index 55b18f6..32a6581 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,11 @@ exclude = ["vendor", "worktrees"] [workspace.package] version = "0.2.1" edition = "2024" -rust-version = "1.88" +# The floor every optional feature must clear too: the MSRV CI job builds +# `--all-features`, and `vendor/tinyagents` (behind the `tinyagents` feature) +# uses `if let` guards, stable since 1.95 and rejected as experimental below +# it. A lower number here would be a number the CI job itself disproves. +rust-version = "1.95" license = "GPL-3.0-only" repository = "https://github.com/tinyhumansai/tinyloops" diff --git a/README.md b/README.md index 5aaa721..d73a779 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,13 @@ the reference seams, drives it to a terminal state, and prints every pass boundary, every step, every arm, the merge, the verdict, and the route each pass took with the counters it was taken on. +`tuned_research_loop` is the same loop with a third arm that may revise the +run's own configuration — its thresholds, its spend, and which arms it is still +paying for — within the room its preset declares. Every revision and every +refusal is an event and a line in the report; nothing here scores them, because +scoring a configuration against outcomes spans runs and lives in +`tinyflows-adaptive`. + Every example runs against TinyFlows' mock capabilities or the reference implementations, so they are deterministic, offline, and need no provider credentials. `tinyagents` is optional: the harness example declares diff --git a/crates/tinyloops/examples/research_loop.rs b/crates/tinyloops/examples/research_loop.rs index 85043f0..71c2e74 100644 --- a/crates/tinyloops/examples/research_loop.rs +++ b/crates/tinyloops/examples/research_loop.rs @@ -88,7 +88,7 @@ fn main() -> Result<(), Error> { )?; println!("preset: {}", assembled.preset()); - println!("stuck at: {}", assembled.thresholds().stuck); + println!("stuck at: {}", assembled.profile().thresholds.stuck); println!("signature: {}\n", assembled.signature()?.as_str()); // A recorder over the console sink: one line per event, in one ordered diff --git a/crates/tinyloops/src/arm/mod.rs b/crates/tinyloops/src/arm/mod.rs index f3ffb3c..a07c1be 100644 --- a/crates/tinyloops/src/arm/mod.rs +++ b/crates/tinyloops/src/arm/mod.rs @@ -55,7 +55,7 @@ mod types; -pub use types::{Arm, ArmOutcome, ArmSet, Edge}; +pub use types::{Arm, ArmOutcome, ArmSet, Edge, Tuner, TunerArm}; use crate::state::LoopState; use crate::{Error, Result}; diff --git a/crates/tinyloops/src/arm/test.rs b/crates/tinyloops/src/arm/test.rs index 1bb00ff..dd5ccba 100644 --- a/crates/tinyloops/src/arm/test.rs +++ b/crates/tinyloops/src/arm/test.rs @@ -484,3 +484,142 @@ fn debug_rendering_names_the_declared_arms() { assert!(rendered.contains("judge")); assert!(format!("{:?}", Edge::new("attempt", "judge")).contains("attempt")); } + +/// A tuner that proposes whatever it was constructed with. +#[derive(Debug)] +struct Fixed { + name: &'static str, + change: Option, +} + +impl crate::arm::Tuner for Fixed { + fn name(&self) -> &'static str { + self.name + } + + fn propose( + &self, + _base: &LoopState, + _report: &Value, + ctx: StepContext<'_, NoWrite>, + ) -> Result> { + Ok(self + .change + .clone() + .map(|change| crate::policy::Amendment::new(self.name, ctx.pass(), change, "because"))) + } +} + +fn tuner(name: &'static str, change: Option) -> Arc { + Arc::new(crate::arm::TunerArm::new(Arc::new(Fixed { name, change }))) +} + +#[test] +fn two_tuners_in_one_arm_set_are_refused_by_name() { + // The same shape as the second concluding arm, and for the same reason: + // two proposers means the profile a pass folds depends on which finished + // first, which is arrival order deciding the run's configuration. + let error = ArmSet::new(vec![tuner("tune", None), tuner("also_tune", None)]) + .expect_err("two tuners are refused"); + + assert_eq!( + error, + Error::AmbiguousTuning { + first: "tune", + second: "also_tune", + } + ); +} + +#[test] +fn a_set_names_its_tuner_and_a_set_without_one_names_nobody() { + let tuned = ArmSet::new(vec![tuner("tune", None)]).expect("one tuner is a valid set"); + assert_eq!(tuned.tuning(), Some("tune")); + assert!(tuned.concluding().is_none()); +} + +#[test] +fn a_tuner_that_proposes_nothing_folds_as_unchanged() { + let base = LoopState::new("goal"); + let arm = tuner("tune", None); + let thresholds = crate::Thresholds::default(); + + let outcome = arm + .evaluate(&base, &Value::Null, StepContext::observing(0, &thresholds)) + .expect("a tuner that declines still returns"); + + assert_eq!(outcome.state, base); + assert!(outcome.contribution.is_empty()); + assert!(outcome.state.proposed().is_none()); +} + +#[test] +fn a_tuner_that_proposes_puts_it_where_the_merge_reads_it() { + let base = LoopState::new("goal"); + let change = crate::policy::Change::Threshold { + field: crate::policy::ThresholdField::Stuck, + to: 3, + }; + let arm = tuner("tune", Some(change.clone())); + let thresholds = crate::Thresholds::default(); + + let outcome = arm + .evaluate(&base, &Value::Null, StepContext::observing(2, &thresholds)) + .expect("a tuner proposes"); + + // Both halves: the claim, for the merge, and the state, for the trip + // through the graph. They have to agree or the proposal is dropped at the + // barrier with nothing to report it. + assert_eq!( + outcome.contribution.amendment().map(|a| a.change.clone()), + Some(change.clone()) + ); + assert_eq!( + outcome.state.proposed().map(|a| a.change.clone()), + Some(change) + ); + assert_eq!(outcome.state.proposed().map(|a| a.pass), Some(2)); +} + +#[test] +fn merge_refuses_two_arms_proposing_an_amendment() { + // The tuner is the only role that *can* propose, but the merge still + // arbitrates: exclusive ownership is a property of the fold, not a promise + // about who calls it. + let base = LoopState::new("goal"); + let change = crate::policy::Change::Threshold { + field: crate::policy::ThresholdField::Stuck, + to: 3, + }; + let thresholds = crate::Thresholds::default(); + + let first = tuner("tune", Some(change.clone())); + let second = tuner("also_tune", Some(change)); + let outcomes = vec![ + first + .evaluate(&base, &Value::Null, StepContext::observing(0, &thresholds)) + .unwrap(), + second + .evaluate(&base, &Value::Null, StepContext::observing(0, &thresholds)) + .unwrap(), + ]; + + let set = ArmSet::new(vec![tuner("tune", None)]).unwrap(); + let deltas: Vec<_> = outcomes + .iter() + .map(|outcome| outcome.state.delta_from(&base)) + .collect(); + let contributions: Vec<_> = outcomes + .into_iter() + .map(|outcome| outcome.contribution) + .collect(); + let _ = set; + + assert!(matches!( + base.merge(&deltas, &contributions), + Err(Error::ContestedField { + field: "amendment", + .. + }) + )); +} diff --git a/crates/tinyloops/src/arm/types.rs b/crates/tinyloops/src/arm/types.rs index 2244b73..b88e864 100644 --- a/crates/tinyloops/src/arm/types.rs +++ b/crates/tinyloops/src/arm/types.rs @@ -11,6 +11,7 @@ use std::sync::Arc; use serde_json::Value; +use crate::policy::Amendment; use crate::state::{Contribution, LoopState}; use crate::step::{NoWrite, StepContext}; use crate::{Error, Result}; @@ -107,6 +108,17 @@ pub trait Arm: Send + Sync { false } + /// Whether this arm may propose an amendment to the run's own profile. + /// + /// `false` for every arm but [`TunerArm`], and an implementor outside this + /// crate answering `true` gains nothing by it: the slot a proposal travels + /// in is crate-private, so the claim is unbacked. It is declared here so + /// [`ArmSet::new`] can refuse a second proposer by the same route it + /// already refuses a second concluding arm. + fn may_tune(&self) -> bool { + false + } + /// Evaluates the pass. /// /// `report` is the output of the node immediately upstream — the attempt's @@ -206,6 +218,7 @@ impl ArmSet { } let mut concluding: Option<&'static str> = None; + let mut tuning: Option<&'static str> = None; for (index, arm) in arms.iter().enumerate() { if arms[..index].iter().any(|prior| prior.name() == arm.name()) { return Err(Error::DuplicateArm { @@ -222,6 +235,16 @@ impl ArmSet { } concluding = Some(arm.name()); } + + if arm.may_tune() { + if let Some(first) = tuning { + return Err(Error::AmbiguousTuning { + first, + second: arm.name(), + }); + } + tuning = Some(arm.name()); + } } Ok(Self { arms }) @@ -266,6 +289,195 @@ impl ArmSet { .find(|arm| arm.may_conclude()) .map(|arm| arm.name()) } + + /// The arm allowed to propose an amendment, if one was declared. + #[must_use] + pub fn tuning(&self) -> Option<&'static str> { + self.arms + .iter() + .find(|arm| arm.may_tune()) + .map(|arm| arm.name()) + } +} + +/// What proposes a change to the run's own configuration. +/// +/// A trait of its own rather than a capability on [`Arm`], and the reason is +/// mechanical: `Arm::evaluate` takes a concrete `StepContext<'_, NoWrite>` and +/// an [`ArmSet`] holds `Arc`, so making the context generic over a +/// third capability marker would cost object safety. Wrapping a `Tuner` in +/// [`TunerArm`] buys the same guarantee for no change to the arm surface: the +/// adapter is the only code that can fill the crate-private slot a proposal +/// travels in, so an `impl Arm` has no way to propose one. +/// +/// # What a tuner should and should not be +/// +/// The shipped one is a pure function of the counters. A model asked mid-run +/// whether its own configuration is wrong has no ground truth to answer from +/// and every incentive to answer yes — the same pressure that makes a model +/// claim the goal is met on the eighth pass. A model tuner is permitted here, +/// and is bounded by exactly the same [`Bounds`](crate::Bounds), which is the +/// point of putting the bounds outside the proposer. +/// +/// # One proposer, proved by what compiles +/// +/// The slot a proposal travels in is crate-private, so an ordinary [`Arm`] has +/// no way to fill it. This is the same shape as +/// [`Advanced`](crate::Advanced) — possession is the proof — and it is checked +/// by the compiler rather than by review. +/// +/// An arm reaching for the accumulator's slot does not compile: +/// +/// ```compile_fail,E0616 +/// # use serde_json::Value; +/// # use tinyloops::{ +/// # Amendment, Arm, ArmOutcome, Change, LoopState, NoWrite, Result, StepContext, +/// # ThresholdField, +/// # }; +/// struct Sneaky; +/// +/// impl Arm for Sneaky { +/// fn name(&self) -> &'static str { +/// "sneaky" +/// } +/// +/// fn evaluate( +/// &self, +/// base: &LoopState, +/// _report: &Value, +/// _ctx: StepContext<'_, NoWrite>, +/// ) -> Result { +/// let mut outcome = ArmOutcome::unchanged("sneaky", base); +/// // error[E0616]: field `proposed` of struct `LoopState` is private +/// outcome.state.proposed = Some(Amendment::new( +/// "sneaky", +/// 0, +/// Change::Threshold { field: ThresholdField::Stuck, to: 99 }, +/// "because I said so", +/// )); +/// Ok(outcome) +/// } +/// } +/// ``` +/// +/// Nor does one reaching for the contribution's: +/// +/// ```compile_fail,E0616 +/// # use serde_json::Value; +/// # use tinyloops::{ +/// # Amendment, Arm, ArmOutcome, Change, LoopState, NoWrite, Result, StepContext, +/// # ThresholdField, +/// # }; +/// struct AlsoSneaky; +/// +/// impl Arm for AlsoSneaky { +/// fn name(&self) -> &'static str { +/// "also_sneaky" +/// } +/// +/// fn evaluate( +/// &self, +/// base: &LoopState, +/// _report: &Value, +/// _ctx: StepContext<'_, NoWrite>, +/// ) -> Result { +/// let mut outcome = ArmOutcome::unchanged("also_sneaky", base); +/// // error[E0616]: field `amendment` of struct `Contribution` is private +/// outcome.contribution.amendment = Some(Amendment::new( +/// "also_sneaky", +/// 0, +/// Change::Threshold { field: ThresholdField::Stuck, to: 99 }, +/// "because I said so", +/// )); +/// Ok(outcome) +/// } +/// } +/// ``` +/// +/// Declaring [`Arm::may_tune`] `true` buys an outside implementor nothing +/// either: the claim is unbacked, and the only effect is that [`ArmSet::new`] +/// starts refusing a second one. +pub trait Tuner: Send + Sync { + /// The arm's name, and the id of its node. + fn name(&self) -> &'static str; + + /// Proposes at most one amendment for the *next* pass. + /// + /// `base` is the accumulator every arm in this superstep was handed, and + /// `report` is the attempt's report — the same two inputs every other arm + /// reads, for the same reason. + /// + /// Returning `None` is the ordinary answer. A tuner that proposes on every + /// pass is a tuner that has mistaken its own budget for a target. + /// + /// # Errors + /// + /// Whatever the implementation raises. A tuner that cannot decide should + /// return `Ok(None)` rather than an error: failing the pass over a + /// configuration question is a worse outcome than not tuning. + fn propose( + &self, + base: &LoopState, + report: &Value, + ctx: StepContext<'_, NoWrite>, + ) -> Result>; +} + +/// The adapter that runs a [`Tuner`] as an evaluation arm. +/// +/// The only writer of the proposal slot in the whole crate. Everything else +/// about it is an ordinary arm: it fans out from the attempt, converges on the +/// barrier, and folds as a zero delta with one narrative claim. +pub struct TunerArm { + tuner: Arc, +} + +impl std::fmt::Debug for TunerArm { + /// Renders the tuner's name; the body is a trait object. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TunerArm") + .field("tuner", &self.tuner.name()) + .finish() + } +} + +impl TunerArm { + /// Wraps `tuner` as an arm. + #[must_use] + pub fn new(tuner: Arc) -> Self { + Self { tuner } + } +} + +impl Arm for TunerArm { + fn name(&self) -> &'static str { + self.tuner.name() + } + + fn may_tune(&self) -> bool { + true + } + + fn evaluate( + &self, + base: &LoopState, + report: &Value, + ctx: StepContext<'_, NoWrite>, + ) -> Result { + let mut outcome = ArmOutcome::unchanged(Arm::name(self), base); + let pass = ctx.pass(); + if let Some(mut amendment) = self.tuner.propose(base, report, ctx)? { + // Stamped here rather than trusted from the tuner: a `Tuner` is a + // trait object an embedder can implement, and an amendment that + // named another arm as proposer or an earlier pass would + // misattribute a revision in the run's own record. + Arm::name(self).clone_into(&mut amendment.proposer); + amendment.pass = pass; + outcome.contribution.amendment = Some(amendment.clone()); + outcome.state.proposed = Some(amendment); + } + Ok(outcome) + } } impl std::fmt::Debug for ArmSet { diff --git a/crates/tinyloops/src/budget/test.rs b/crates/tinyloops/src/budget/test.rs index 12affd5..53dfcc7 100644 --- a/crates/tinyloops/src/budget/test.rs +++ b/crates/tinyloops/src/budget/test.rs @@ -79,7 +79,7 @@ fn rejects_a_zero_run_clock() { #[test] fn rejects_a_tool_timeout_that_outlives_the_run() { let caps = Caps { - tool_timeout: Duration::from_secs(60 * 60), + tool_timeout: Duration::from_hours(1), ..valid() }; assert_eq!( @@ -203,9 +203,9 @@ fn narrowing_clamps_every_cap_to_the_parent() { max_model_calls: 1_000, max_tool_calls: 100_000, max_tokens: 999_999_999, - run_timeout: Duration::from_secs(60 * 60 * 24), - tool_timeout: Duration::from_secs(60 * 60), - request_timeout: Duration::from_secs(60 * 30), + run_timeout: Duration::from_hours(24), + tool_timeout: Duration::from_hours(1), + request_timeout: Duration::from_mins(30), max_retries: 99, }; diff --git a/crates/tinyloops/src/budget/types.rs b/crates/tinyloops/src/budget/types.rs index d152884..bab0ba9 100644 --- a/crates/tinyloops/src/budget/types.rs +++ b/crates/tinyloops/src/budget/types.rs @@ -174,12 +174,19 @@ impl Default for Caps { /// not be constructed is a default nobody can use. fn default() -> Self { Self { - max_iterations: 8, + // The runaway backstop, not the routing ceiling: `max_attempts` + // decides when a run stops trying and lives in the accumulator. + // This has to clear the largest `max_attempts` any shipped preset + // asks for — twelve, for `Preset::Persistent` — or that preset is + // truncated four attempts short of the ceiling it declares, and + // nothing says so. `src/presets/test.rs` asserts the relationship + // rather than the number. + max_iterations: 12, max_model_calls: 60, // 60 * 8 = 480 is the reachability floor; 600 clears it. max_tool_calls: 600, max_tokens: 2_000_000, - run_timeout: Duration::from_secs(30 * 60), + run_timeout: Duration::from_mins(30), tool_timeout: Duration::from_secs(120), request_timeout: Duration::from_secs(60), max_retries: 3, diff --git a/crates/tinyloops/src/error/mod.rs b/crates/tinyloops/src/error/mod.rs index 105645a..513f493 100644 --- a/crates/tinyloops/src/error/mod.rs +++ b/crates/tinyloops/src/error/mod.rs @@ -176,6 +176,63 @@ pub enum Error { second: &'static str, }, + /// More than one arm declared itself able to propose an amendment. + /// + /// Exactly one may. Two means the profile a pass folds depends on which of + /// them finished first, which is arrival order deciding the run's + /// configuration — the same failure [`Self::AmbiguousConclusion`] names, + /// arriving at the one field that governs every later route. + #[error("both {first} and {second} may tune the loop")] + AmbiguousTuning { + /// The arm that declared it first. + first: &'static str, + /// The arm that also declared it. + second: &'static str, + }, + + /// An amendment named a field or an arm the bounds do not mention. + /// + /// A field with no entry cannot be moved at all. That is the safe default + /// and the deliberate one: bounds written without thinking about a field are + /// bounds that do not let a tuner touch it. + #[error("nothing bounds {field}, so it cannot be amended")] + UnboundedAmendment { + /// The field or arm the amendment named. + field: String, + }, + + /// A `Bounds` names an arm mutable that the run's declared `ArmSet` + /// cannot give that room to. + /// + /// Caught at assembly, before a tuner ever proposes: an arm not in the + /// set has no room to mute at all, so the amendment would be a recorded + /// no-op that spends the run's amendment budget on nothing, and the + /// concluding arm is refused unconditionally, because a run that mutes + /// the one arm able to end it cannot succeed. + #[error("{arm} cannot be named mutable: {reason}")] + IneligibleMutableArm { + /// The arm the bounds tried to make mutable. + arm: String, + /// Why it is not eligible. + reason: &'static str, + }, + + /// An amendment proposed a value outside the range its bounds allow. + /// + /// Refused rather than clamped. A clamped proposal reads as accepted at the + /// proposer and as a no-op in the state, and nothing joins the two. + #[error("{field} may be {low}..={high}, not {value}")] + AmendmentOutOfBounds { + /// The field the amendment named. + field: String, + /// The value it proposed. + value: u64, + /// The lowest value the bounds allow. + low: u64, + /// The highest value the bounds allow. + high: u64, + }, + /// A merge was handed an outcome from an arm the set does not declare. /// /// Folding it would credit the run with evidence no declared arm produced. diff --git a/crates/tinyloops/src/error/test.rs b/crates/tinyloops/src/error/test.rs index 4c5d609..025337f 100644 --- a/crates/tinyloops/src/error/test.rs +++ b/crates/tinyloops/src/error/test.rs @@ -15,3 +15,42 @@ fn is_a_standard_error() { assert_error(&Error::EmptyName); } + +#[test] +fn the_amendment_refusals_render_the_messages_a_reader_will_see() { + // These two strings are what a refusal carries into the run's record and + // its report, so they are read by people rather than matched by code. + assert_eq!( + Error::UnboundedAmendment { + field: "max_tokens".to_owned(), + } + .to_string(), + "nothing bounds max_tokens, so it cannot be amended", + ); + assert_eq!( + Error::AmendmentOutOfBounds { + field: "stuck".to_owned(), + value: 9, + low: 1, + high: 4, + } + .to_string(), + "stuck may be 1..=4, not 9", + ); + assert_eq!( + Error::AmbiguousTuning { + first: "tune", + second: "also_tune", + } + .to_string(), + "both tune and also_tune may tune the loop", + ); + assert_eq!( + Error::IneligibleMutableArm { + arm: "reflect".to_owned(), + reason: "it is the run's concluding arm", + } + .to_string(), + "reflect cannot be named mutable: it is the run's concluding arm", + ); +} diff --git a/crates/tinyloops/src/lib.rs b/crates/tinyloops/src/lib.rs index b184ba8..50d6ca5 100644 --- a/crates/tinyloops/src/lib.rs +++ b/crates/tinyloops/src/lib.rs @@ -83,8 +83,10 @@ pub use observe::{ // goal run carries between turns; `policy` is the routing that reads it, in // both the Rust and the jq spelling. pub use policy::{ - Autonomy, Judgement, Outcome, Route, Thresholds, evaluate_ladder, evaluate_terminal_condition, - expr_scope, is_terminal, ladder, route, terminal_condition, + Amendment, Autonomy, Bounds, CapField, Change, DEFAULT_MAX_AMENDMENTS, DEFAULT_MUTING_WINDOW, + Judgement, LoopProfile, Muted, Outcome, Range, Recorded, Route, ThresholdField, Thresholds, + Verdict, evaluate_ladder, evaluate_terminal_condition, expr_scope, is_terminal, ladder, route, + terminal_condition, }; pub use state::{Contribution, Delta, LoopState}; @@ -114,7 +116,7 @@ pub use memory::{ // The loop body: the arms a pass fans out to, and the closed set of steps their // nodes are. `arm` owns the one list both arm edge sets are derived from and the // merge that folds them; `step` owns the single tool a node body is. -pub use arm::{Arm, ArmOutcome, ArmSet, Edge, upstream_address}; +pub use arm::{Arm, ArmOutcome, ArmSet, Edge, Tuner, TunerArm, upstream_address}; // The graph the loop is: one builder emitting one `WorkflowGraph`, the // signature a checkpoint carries so a changed topology refuses a resume rather // than corrupting one, and the composable condition that says when — and why — @@ -127,8 +129,8 @@ pub use loops::{ // down, the two evaluation arms that keep a verdict mechanical, and a loop with // every seam already filled in. pub use presets::{ - Advance, ArmStep, AssembledLoop, Converge, Driven, Gather, Judge, Preset, Reflect, - SOLVED_MARKER, research_loop, + Advance, ArmStep, AssembledLoop, Converge, Driven, Gather, Judge, Preset, Reflect, Rules, + SILENT_SCORES, SOLVED_MARKER, research_loop, tuned_research_loop, }; pub use step::{ AccumulatorAccess, Advanced, CanWrite, NoWrite, Observer, RUN_LOOP_STEP, RegisteredStep, diff --git a/crates/tinyloops/src/loops/builder.rs b/crates/tinyloops/src/loops/builder.rs index da77b97..9ac9817 100644 --- a/crates/tinyloops/src/loops/builder.rs +++ b/crates/tinyloops/src/loops/builder.rs @@ -10,7 +10,8 @@ use tinyflows::model::{Edge as GraphEdge, Node, NodeKind, Port, WorkflowGraph}; use super::termination::TerminationCondition; use super::types::{NodeIds, payload_address}; use crate::arm::ArmSet; -use crate::policy::{Autonomy, Route, Thresholds, ladder}; +use crate::budget::Caps; +use crate::policy::{Autonomy, LoopProfile, Route, ladder}; use crate::state::LoopState; use crate::step::{ RUN_LOOP_STEP, STEP_ATTEMPT, STEP_PASS, STEP_PLAN, STEP_REPORT, STEP_RESEARCH, StepRegistry, @@ -59,7 +60,7 @@ const DEFAULT_PORT: &str = "default"; /// # use serde_json::Value; /// # use tinyloops::{ /// # Advanced, Arm, ArmOutcome, ArmSet, Autonomy, CanWrite, LoopBuilder, LoopState, NoWrite, -/// # Result, STEP_MERGE, Step, StepContext, StepRegistry, Thresholds, +/// # Result, STEP_MERGE, Step, StepContext, StepRegistry, /// # }; /// struct Body(&'static str); /// @@ -100,7 +101,7 @@ const DEFAULT_PORT: &str = "default"; /// Arc::new(Evaluator("judge")), /// ])?; /// -/// let graph = LoopBuilder::new(Thresholds::default(), arms, registry) +/// let graph = LoopBuilder::new(arms, registry) /// .goal("ship the release") /// .autonomy(Autonomy::Unattended) /// .build()?; @@ -110,7 +111,8 @@ const DEFAULT_PORT: &str = "default"; /// ``` #[derive(Debug, Clone)] pub struct LoopBuilder { - thresholds: Thresholds, + profile: LoopProfile, + caps: Caps, arms: ArmSet, registry: StepRegistry, ids: NodeIds, @@ -129,10 +131,17 @@ impl LoopBuilder { /// stands down, and reports; it emits no attempt, no arms, and no loop. Ask /// for [`Autonomy::Assisted`] or [`Autonomy::Unattended`] to get a graph /// that acts. + /// + /// The profile defaults to the balanced preset and the caps to + /// [`Caps::default`]. Neither is a constructor argument any more: the + /// profile is not topology — it is seeded into the accumulator and read + /// from there — so two builders differing only in their thresholds emit the + /// same graph, and the same signature. #[must_use] - pub fn new(thresholds: Thresholds, arms: ArmSet, registry: StepRegistry) -> Self { + pub fn new(arms: ArmSet, registry: StepRegistry) -> Self { Self { - thresholds, + profile: LoopProfile::default(), + caps: Caps::default(), arms, registry, ids: NodeIds::default(), @@ -143,6 +152,32 @@ impl LoopBuilder { } } + /// The profile the run seeds its accumulator with. + /// + /// It does not change the emitted topology. The routing ladder addresses + /// `.profile.thresholds` in the accumulator rather than carrying the + /// numbers, so this changes what the run *decides* and not what the graph + /// *is* — which is what lets a run that later revises its own thresholds + /// resume from a checkpoint taken before it did. + #[must_use] + pub fn profile(mut self, profile: LoopProfile) -> Self { + self.profile = profile; + self + } + + /// The run's limits. + /// + /// Only [`Caps::max_iterations`] reaches the graph, as the loop head's + /// runaway backstop. It is deliberately not a threshold: a threshold + /// decides where a pass routes and lives in the accumulator, while this + /// bounds how many passes the engine will run at all and has to be a + /// literal the head can read without evaluating anything. + #[must_use] + pub fn caps(mut self, caps: Caps) -> Self { + self.caps = caps; + self + } + /// The goal the run seeds its accumulator with. #[must_use] pub fn goal(mut self, goal: impl Into) -> Self { @@ -191,8 +226,11 @@ impl LoopBuilder { /// - [`Error::InvalidLoopGraph`] when the emitted graph does not pass the /// engine's own structural validation. pub fn build(self) -> Result { - let seed = serde_json::to_value(LoopState::new(self.goal.clone())) - .map_err(|_| Error::StateEncoding)?; + let seed = serde_json::to_value(LoopState::with_profile( + self.goal.clone(), + self.profile.clone(), + )) + .map_err(|_| Error::StateEncoding)?; let (nodes, edges) = if self.autonomy == Autonomy::Report { self.dry_run_shape(&seed) @@ -440,13 +478,18 @@ impl LoopBuilder { type_version: 1, name: "the goal loop".to_string(), config: json!({ - // Every number here is interpolated from `Thresholds`. - "max_iterations": self.thresholds.max_attempts, + // The one number in this graph, and deliberately not a + // threshold: it is the engine's runaway backstop, not a + // routing decision. `max_attempts` lives in the accumulator + // with every other threshold, so raising it mid-run buys the + // extra passes it promises rather than being silently capped + // here. + "max_iterations": self.caps.max_iterations, // `continue` rather than `error`: a run that spent its attempts // still has to reach `stand_down` and `report`, and a run that // failed at the head reports nothing about why it stopped. "on_exceeded": "continue", - "until": self.termination.expression(&self.thresholds), + "until": self.termination.expression(), "emit": "state", "state": { "init": payload_address(ids.research), @@ -481,26 +524,29 @@ impl LoopBuilder { kind: NodeKind::Switch, type_version: 1, name: "route the pass".to_string(), - config: json!({ "expression": self.routing_expression() }), + config: json!({ "expression": routing_expression() }), ports, position: None, } } +} - /// The jq the routing switch branches on. - /// - /// [`ladder`] rendered verbatim, with one reshaping pipe in front of it. - /// The ladder reads its accumulator as `.state // .item`, and at a switch - /// there is no `state` key and `item` is the barrier's output envelope, so - /// the pipe presents the folded state where the ladder already looks for - /// it. Composing rather than re-rendering is the point: not one threshold - /// is typed here, and the program the graph runs is the program - /// `src/policy/` generates. - fn routing_expression(&self) -> String { - let rendered = ladder(&self.thresholds); - let body = rendered.strip_prefix('=').unwrap_or(&rendered); - format!("={{ item: .item.json }} | ({body})") - } +/// The jq the routing switch branches on. +/// +/// [`ladder`] rendered verbatim, with one reshaping pipe in front of it. The +/// ladder reads its accumulator as `.state // .item`, and at a switch there is +/// no `state` key and `item` is the barrier's output envelope, so the pipe +/// presents the folded state where the ladder already looks for it. Composing +/// rather than re-rendering is the point: not one threshold is typed here, and +/// the program the graph runs is the program `src/policy/` generates. +/// +/// A free function rather than a method because it no longer reads anything off +/// the builder — the ladder is a constant now, the same program for every +/// profile. +fn routing_expression() -> String { + let rendered = ladder(); + let body = rendered.strip_prefix('=').unwrap_or(&rendered); + format!("={{ item: .item.json }} | ({body})") } /// The head's fold expression: the state `pass` returned, or the seed. diff --git a/crates/tinyloops/src/loops/termination.rs b/crates/tinyloops/src/loops/termination.rs index 01e20d4..52ca43b 100644 --- a/crates/tinyloops/src/loops/termination.rs +++ b/crates/tinyloops/src/loops/termination.rs @@ -24,7 +24,7 @@ use std::ops::{BitAnd, BitOr}; use serde::{Deserialize, Serialize}; -use crate::policy::{Outcome, Thresholds, is_terminal, terminal_condition}; +use crate::policy::{Outcome, is_terminal, terminal_condition}; use crate::state::LoopState; /// The named state a finished run ended in. @@ -67,18 +67,17 @@ enum Rule { /// # Examples /// /// ``` -/// # use tinyloops::{LoopState, Outcome, TerminationCondition, Thresholds}; -/// let thresholds = Thresholds::default(); +/// # use tinyloops::{LoopState, Outcome, TerminationCondition}; /// let mut condition = TerminationCondition::terminal() | TerminationCondition::expired(); /// /// let mut state = LoopState::new("goal"); -/// assert_eq!(condition.evaluate(&state, &thresholds), None); +/// assert_eq!(condition.evaluate(&state), None); /// /// // Out of attempts is out of attempts, however hopeful the last pass was. /// state.solved = true; /// state.banked = 1; -/// state.attempts = thresholds.max_attempts; -/// assert_eq!(condition.evaluate(&state, &thresholds), Some(Outcome::Exhausted)); +/// state.attempts = state.profile.thresholds.max_attempts; +/// assert_eq!(condition.evaluate(&state), Some(Outcome::Exhausted)); /// /// condition.reset(); /// assert_eq!(condition.fired(), None); @@ -132,13 +131,13 @@ impl TerminationCondition { } /// Whether the rule holds for `state`, ignoring the latch. - fn holds(&self, state: &LoopState, thresholds: &Thresholds) -> bool { + fn holds(&self, state: &LoopState) -> bool { match &self.rule { - Rule::Terminal => is_terminal(state, thresholds), + Rule::Terminal => is_terminal(state), Rule::Expired => state.expired, Rule::Solved => state.solved, - Rule::All(inner) => inner.iter().all(|c| c.holds(state, thresholds)), - Rule::Any(inner) => inner.iter().any(|c| c.holds(state, thresholds)), + Rule::All(inner) => inner.iter().all(|c| c.holds(state)), + Rule::Any(inner) => inner.iter().any(|c| c.holds(state)), } } @@ -151,14 +150,14 @@ impl TerminationCondition { /// The reported outcome is always [`Outcome::classify`], never a value the /// caller chose: that is where "an error or an exhausted budget is never /// [`Outcome::Success`]" is enforced. - pub fn evaluate(&mut self, state: &LoopState, thresholds: &Thresholds) -> Option { + pub fn evaluate(&mut self, state: &LoopState) -> Option { if let Some(outcome) = self.fired { return Some(outcome); } - if !self.holds(state, thresholds) { + if !self.holds(state) { return None; } - let outcome = Outcome::classify(state, thresholds); + let outcome = Outcome::classify(state); self.fired = Some(outcome); Some(outcome) } @@ -197,33 +196,30 @@ impl TerminationCondition { /// "stop when all of nothing holds" stops immediately, and one given "stop /// when any of nothing holds" runs to its cap. #[must_use] - pub fn expression(&self, thresholds: &Thresholds) -> String { - format!("={}", self.program(thresholds)) + pub fn expression(&self) -> String { + format!("={}", self.program()) } /// The `=`-less body, so a composed rule can nest it. - fn program(&self, thresholds: &Thresholds) -> String { + fn program(&self) -> String { match &self.rule { Rule::Terminal => { - let rendered = terminal_condition(thresholds); + let rendered = terminal_condition(); let body = rendered.strip_prefix('=').unwrap_or(&rendered); format!("({body})") } Rule::Expired => "((.state // .item) as $s | (($s | .expired) // false))".to_string(), Rule::Solved => "((.state // .item) as $s | (($s | .solved) // false))".to_string(), - Rule::All(inner) => Self::join(inner, thresholds, "and", "true"), - Rule::Any(inner) => Self::join(inner, thresholds, "or", "false"), + Rule::All(inner) => Self::join(inner, "and", "true"), + Rule::Any(inner) => Self::join(inner, "or", "false"), } } - fn join(inner: &[Self], thresholds: &Thresholds, operator: &str, empty: &str) -> String { + fn join(inner: &[Self], operator: &str, empty: &str) -> String { if inner.is_empty() { return format!("({empty})"); } - let parts: Vec = inner - .iter() - .map(|condition| condition.program(thresholds)) - .collect(); + let parts: Vec = inner.iter().map(Self::program).collect(); format!("({})", parts.join(&format!(" {operator} "))) } } diff --git a/crates/tinyloops/src/loops/test.rs b/crates/tinyloops/src/loops/test.rs index ad49b0c..b8c7da4 100644 --- a/crates/tinyloops/src/loops/test.rs +++ b/crates/tinyloops/src/loops/test.rs @@ -19,7 +19,7 @@ use super::builder::STEP_MERGE; use super::types::mentions; use super::{GraphSignature, LoopBuilder, NodeIds, TerminationCondition, verify_resume}; use crate::arm::{Arm, ArmOutcome, ArmSet}; -use crate::policy::{Autonomy, Route, Thresholds}; +use crate::policy::{Autonomy, LoopProfile, Route, Thresholds}; use crate::state::LoopState; use crate::step::{ Advanced, CanWrite, NoWrite, STEP_ATTEMPT, STEP_JUDGE, STEP_PASS, STEP_PLAN, STEP_REFLECT, @@ -88,18 +88,28 @@ fn arms() -> ArmSet { .expect("two distinct arms are a valid set") } -/// A graph that acts, at the given autonomy. -fn graph_at(autonomy: Autonomy, thresholds: Thresholds) -> WorkflowGraph { - LoopBuilder::new(thresholds, arms(), registry()) +/// A graph that acts, at the given autonomy and profile. +fn graph_at(autonomy: Autonomy, profile: LoopProfile) -> WorkflowGraph { + LoopBuilder::new(arms(), registry()) .goal("ship the release") .autonomy(autonomy) + .profile(profile) .build() .expect("the fixture builds a valid graph") } /// The default unattended graph. fn graph() -> WorkflowGraph { - graph_at(Autonomy::Unattended, Thresholds::default()) + graph_at(Autonomy::Unattended, LoopProfile::default()) +} + +/// A profile carrying `thresholds`, for the tests about what does and does not +/// move the signature. +fn profile(thresholds: Thresholds) -> LoopProfile { + LoopProfile { + thresholds, + ..LoopProfile::default() + } } #[test] @@ -236,7 +246,7 @@ fn node_ids_are_declared_not_positional() { attempt: "try", ..NodeIds::default() }; - let graph = LoopBuilder::new(Thresholds::default(), arms(), registry()) + let graph = LoopBuilder::new(arms(), registry()) .autonomy(Autonomy::Unattended) .ids(ids) .build() @@ -316,7 +326,7 @@ fn fan_out_and_fold_inputs_name_the_same_arms() { fn removing_an_arm_removes_it_from_both_the_fan_out_and_the_fold() { let one = ArmSet::new(vec![Arc::new(Evaluator(STEP_REFLECT)) as Arc]) .expect("one arm is a valid set"); - let graph = LoopBuilder::new(Thresholds::default(), one, registry()) + let graph = LoopBuilder::new(one, registry()) .autonomy(Autonomy::Unattended) .build() .expect("a one-armed loop builds"); @@ -334,37 +344,48 @@ fn removing_an_arm_removes_it_from_both_the_fan_out_and_the_fold() { } #[test] -fn no_threshold_is_typed_into_the_builder() { - let thresholds = Thresholds { - max_attempts: 41, - blocked: 37, - unverified: 29, - stuck: 23, - computational: 19, - max_restarts: 17, - plan_interval: 13, - }; - let graph = graph_at(Autonomy::Unattended, thresholds); +fn no_threshold_reaches_the_builder_at_all() { + // The guard this replaces asserted that every threshold was rendered into + // the emitted programs, and that the defaults' numbers were absent because + // they had not been typed. Both halves flip: no threshold is rendered, and + // the programs address the accumulator instead. What is still being guarded + // is the same thing — a second copy of a constant, free to drift. + let graph = graph_at( + Autonomy::Unattended, + profile(Thresholds { + max_attempts: 41, + blocked: 37, + unverified: 29, + stuck: 23, + computational: 19, + max_restarts: 17, + plan_interval: 13, + }), + ); let ids = NodeIds::default(); let head = graph.node(ids.loop_head).expect("the head is emitted"); - assert_eq!(head.config["max_iterations"], json!(41)); + // The head's cap is the budget's runaway backstop, never `max_attempts`: + // an amendment raising the attempt ceiling would otherwise fold, read back + // as raised, and buy nothing. + assert_eq!( + head.config["max_iterations"], + json!(crate::budget::Caps::default().max_iterations) + ); let until = head.config["until"].as_str().expect("`until` is a program"); - assert!(until.contains(">= 41"), "{until}"); - assert!(until.contains(">= 17"), "{until}"); + assert!(until.contains(".profile.thresholds"), "{until}"); let route = graph.node(ids.route).expect("the switch is emitted"); let program = route.config["expression"] .as_str() .expect("the switch keys on a program"); - for rendered in [">= 37", ">= 41", ">= 29", ">= 23", ">= 19"] { + assert!(program.contains(".profile.thresholds"), "{program}"); + for rendered in [">= 37", ">= 41", ">= 29", ">= 23", ">= 19", ">= 8"] { assert!( - program.contains(rendered), - "{rendered} missing from {program}" + !program.contains(rendered), + "{rendered} was rendered into {program}" ); } - // The default thresholds' numbers cannot appear: they were never typed. - assert!(!program.contains(">= 8"), "{program}"); } #[test] @@ -391,7 +412,7 @@ fn the_accumulator_update_is_an_assignment_not_an_increment() { #[test] fn the_emitted_graph_validates_and_compiles() { for autonomy in [Autonomy::Report, Autonomy::Assisted, Autonomy::Unattended] { - let graph = graph_at(autonomy, Thresholds::default()); + let graph = graph_at(autonomy, LoopProfile::default()); tinyflows::validate::validate(&graph).expect("the emitted graph validates"); tinyflows::compiler::compile(&graph).expect("the emitted graph compiles"); } @@ -420,7 +441,7 @@ fn a_step_absent_from_the_registry_is_a_build_error() { .register(Arc::new(Body(name))) .expect("each step is registered once"); } - let error = LoopBuilder::new(Thresholds::default(), arms(), missing) + let error = LoopBuilder::new(arms(), missing) .autonomy(Autonomy::Unattended) .build() .expect_err("a node naming an unregistered step cannot build"); @@ -454,7 +475,7 @@ fn a_graph_that_fails_validation_is_a_named_error() { #[test] fn assisted_emits_an_approval_point_and_unattended_does_not() { let ids = NodeIds::default(); - let assisted = graph_at(Autonomy::Assisted, Thresholds::default()); + let assisted = graph_at(Autonomy::Assisted, LoopProfile::default()); let approval = assisted.node(ids.approval).expect("assisted asks"); assert_eq!(approval.kind, NodeKind::Approval); // The attempt is reachable only through it. @@ -466,7 +487,7 @@ fn assisted_emits_an_approval_point_and_unattended_does_not() { .collect(); assert_eq!(into_attempt, [ids.approval]); - let unattended = graph_at(Autonomy::Unattended, Thresholds::default()); + let unattended = graph_at(Autonomy::Unattended, LoopProfile::default()); assert!(unattended.node(ids.approval).is_none()); let into_attempt: Vec<&str> = unattended .edges @@ -479,7 +500,7 @@ fn assisted_emits_an_approval_point_and_unattended_does_not() { #[test] fn report_autonomy_emits_no_node_that_acts() { - let graph = graph_at(Autonomy::Report, Thresholds::default()); + let graph = graph_at(Autonomy::Report, LoopProfile::default()); let ids = NodeIds::default(); for absent in [ids.loop_head, ids.attempt, ids.merge, ids.route, ids.pass] { assert!(graph.node(absent).is_none(), "{absent} acts"); @@ -505,24 +526,132 @@ fn the_signature_is_stable_across_two_builds() { assert!(verify_resume(&GraphSignature::of(&graph()), &graph()).is_ok()); } -#[test] -fn changing_a_threshold_changes_the_signature() { - let before = GraphSignature::of(&graph_at(Autonomy::Unattended, Thresholds::default())); - let after = GraphSignature::of(&graph_at( +/// The two programs a threshold used to be rendered into. +fn programs(graph: &WorkflowGraph) -> (String, String) { + let ids = NodeIds::default(); + let head = graph.node(ids.loop_head).expect("the head is emitted"); + let route = graph.node(ids.route).expect("the switch is emitted"); + ( + head.config["until"] + .as_str() + .expect("`until` is a program") + .to_string(), + route.config["expression"] + .as_str() + .expect("the switch keys on a program") + .to_string(), + ) +} + +#[test] +fn the_routing_programs_are_the_same_under_every_preset() { + // The inverse of the assertion this replaces, and the point of the change + // it tests. A threshold used to be rendered into both of these programs, so + // changing one changed the topology; now both address the accumulator and + // one pair of programs routes every preset. + let baseline = programs(&graph()); + for preset in crate::presets::Preset::ALL { + assert_eq!( + programs(&graph_at(Autonomy::Unattended, LoopProfile::of(preset))), + baseline, + "{preset} emitted a different routing program", + ); + } + + let head = graph_at( Autonomy::Unattended, - Thresholds { - stuck: 5, + profile(Thresholds { + max_attempts: 41, ..Thresholds::default() - }, - )); - assert_ne!(before, after); + }), + ); + assert_eq!(programs(&head), baseline); + assert_eq!( + head.node(NodeIds::default().loop_head) + .expect("the head") + .config["max_iterations"], + json!(crate::budget::Caps::default().max_iterations), + ); +} + +#[test] +fn revising_a_threshold_leaves_the_graph_untouched() { + // The property the addressing change exists to buy, stated directly. A run + // that revises its thresholds moves them in its *accumulator*; the graph is + // built once and is not a function of them, so the signature a checkpoint + // recorded still verifies and the resume is allowed. + let built = graph(); + let recorded = GraphSignature::of(&built); + + let mut revised = LoopState::new("ship the release"); + revised.unproductive = 2; + assert_eq!(crate::policy::route(&revised), Route::Diversify); + revised.profile.thresholds.stuck = 4; + revised.profile.revision = 1; + assert_eq!( + crate::policy::route(&revised), + Route::Retry, + "the revision reached the router", + ); + + assert_eq!(GraphSignature::of(&graph()), recorded); + assert!(verify_resume(&recorded, &graph()).is_ok()); +} + +#[test] +fn a_different_preset_is_a_different_run_but_not_a_different_ladder() { + // Worth pinning because it is the one place the change stops short of the + // slogan. The starting profile is seeded into the accumulator the `plan` + // node is handed, exactly as the goal is, so two presets do emit different + // graphs — they are different runs. What they no longer differ in is the + // routing, which is what a resume across a *revision* depends on. + let balanced = graph_at( + Autonomy::Unattended, + LoopProfile::of(crate::presets::Preset::Balanced), + ); + let persistent = graph_at( + Autonomy::Unattended, + LoopProfile::of(crate::presets::Preset::Persistent), + ); + + assert_ne!( + GraphSignature::of(&balanced), + GraphSignature::of(&persistent) + ); + assert_eq!(programs(&balanced), programs(&persistent)); +} + +#[test] +fn the_emitted_graph_renders_no_threshold_into_a_program() { + let graph = graph_at( + Autonomy::Unattended, + profile(Thresholds { + max_attempts: 41, + stuck: 37, + blocked: 29, + computational: 23, + unverified: 19, + max_restarts: 17, + plan_interval: 13, + }), + ); + let (until, expression) = programs(&graph); + + for rendered in [ + ">= 41", ">= 37", ">= 29", ">= 23", ">= 19", ">= 17", ">= 8", ">= 2", + ] { + assert!(!until.contains(rendered), "{rendered} in {until}"); + assert!(!expression.contains(rendered), "{rendered} in {expression}"); + } + assert!(until.contains(".profile.thresholds")); + assert!(expression.contains(".profile.thresholds")); } #[test] fn adding_an_arm_changes_the_signature() { let one = ArmSet::new(vec![Arc::new(Evaluator(STEP_REFLECT)) as Arc]) .expect("one arm is a valid set"); - let smaller = LoopBuilder::new(Thresholds::default(), one, registry()) + let smaller = LoopBuilder::new(one, registry()) .goal("ship the release") .autonomy(Autonomy::Unattended) .build() @@ -532,14 +661,11 @@ fn adding_an_arm_changes_the_signature() { #[test] fn resuming_against_a_mismatched_signature_is_a_named_error() { - let recorded = GraphSignature::of(&graph_at(Autonomy::Unattended, Thresholds::default())); - let current = graph_at( - Autonomy::Unattended, - Thresholds { - blocked: 9, - ..Thresholds::default() - }, - ); + // Topology still moves the hash; only values left it. A graph emitted at a + // different autonomy has different nodes, which is a resume that must be + // refused. + let recorded = GraphSignature::of(&graph_at(Autonomy::Unattended, LoopProfile::default())); + let current = graph_at(Autonomy::Assisted, LoopProfile::default()); let error = verify_resume(&recorded, ¤t).expect_err("a changed topology refuses"); match error { Error::GraphSignatureMismatch { @@ -565,7 +691,7 @@ fn an_exhausted_budget_is_never_success() { state.attempts = thresholds.max_attempts; let outcome = condition - .evaluate(&state, &thresholds) + .evaluate(&state) .expect("a spent budget stops the run"); assert_eq!(outcome, crate::Outcome::Exhausted); assert_ne!(outcome, crate::Outcome::Success); @@ -577,48 +703,43 @@ fn a_provider_failure_reports_blocked() { let mut condition = TerminationCondition::terminal(); let mut state = LoopState::new("goal"); state.blocked = thresholds.blocked; - assert_eq!( - condition.evaluate(&state, &thresholds), - Some(crate::Outcome::Blocked), - ); + assert_eq!(condition.evaluate(&state), Some(crate::Outcome::Blocked),); } #[test] fn conditions_compose_with_and_and_or() { - let thresholds = Thresholds::default(); let mut state = LoopState::new("goal"); state.expired = true; let mut either = TerminationCondition::solved() | TerminationCondition::expired(); - assert!(either.evaluate(&state, &thresholds).is_some()); + assert!(either.evaluate(&state).is_some()); let mut both = TerminationCondition::solved() & TerminationCondition::expired(); - assert_eq!(both.evaluate(&state, &thresholds), None); + assert_eq!(both.evaluate(&state), None); state.solved = true; let mut both = TerminationCondition::solved() & TerminationCondition::expired(); - assert!(both.evaluate(&state, &thresholds).is_some()); + assert!(both.evaluate(&state).is_some()); // The identities of the two operators. let mut none_of = TerminationCondition::any(Vec::new()); - assert_eq!(none_of.evaluate(&LoopState::new("g"), &thresholds), None); + assert_eq!(none_of.evaluate(&LoopState::new("g")), None); let mut all_of = TerminationCondition::all(Vec::new()); - assert!(all_of.evaluate(&LoopState::new("g"), &thresholds).is_some()); - assert!(all_of.expression(&thresholds).contains("true")); + assert!(all_of.evaluate(&LoopState::new("g")).is_some()); + assert!(all_of.expression().contains("true")); assert!( TerminationCondition::any(Vec::new()) - .expression(&thresholds) + .expression() .contains("false"), ); } #[test] fn a_condition_round_trips_through_serde() { - let thresholds = Thresholds::default(); let mut condition = TerminationCondition::terminal() | TerminationCondition::expired(); let mut state = LoopState::new("goal"); state.expired = true; - condition.evaluate(&state, &thresholds); + condition.evaluate(&state); let encoded = serde_json::to_string(&condition).expect("a condition serializes"); let decoded: TerminationCondition = @@ -629,17 +750,16 @@ fn a_condition_round_trips_through_serde() { #[test] fn resetting_a_fired_condition_clears_it() { - let thresholds = Thresholds::default(); let mut condition = TerminationCondition::terminal() & TerminationCondition::expired(); let mut state = LoopState::new("goal"); state.expired = true; - assert!(condition.evaluate(&state, &thresholds).is_some()); + assert!(condition.evaluate(&state).is_some()); assert!(condition.fired().is_some()); condition.reset(); assert_eq!(condition.fired(), None); assert_eq!( - condition.evaluate(&LoopState::new("goal"), &thresholds), + condition.evaluate(&LoopState::new("goal")), None, "a reset condition re-decides rather than replaying its latch", ); @@ -647,29 +767,24 @@ fn resetting_a_fired_condition_clears_it() { #[test] fn a_composed_termination_is_what_the_head_runs() { - let thresholds = Thresholds::default(); let condition = TerminationCondition::solved() | TerminationCondition::expired(); - let graph = LoopBuilder::new(thresholds, arms(), registry()) + let graph = LoopBuilder::new(arms(), registry()) .autonomy(Autonomy::Unattended) .termination(condition.clone()) .build() .expect("a composed condition still builds"); let head = graph.node(NodeIds::default().loop_head).expect("the head"); - assert_eq!( - head.config["until"], - json!(condition.expression(&thresholds)) - ); + assert_eq!(head.config["until"], json!(condition.expression())); } #[test] fn the_termination_expression_evaluates_rather_than_yielding_null() { // Under this engine a program that fails to compile yields `null`, and // `null` is falsey — so "it produced a boolean" is itself the assertion. - let thresholds = Thresholds::default(); let mut state = LoopState::new("goal"); state.expired = true; let condition = TerminationCondition::terminal() | TerminationCondition::solved(); - let program = Value::String(condition.expression(&thresholds)); + let program = Value::String(condition.expression()); let scope = json!({ "state": serde_json::to_value(&state).expect("state encodes") }); assert_eq!(tinyflows::expr::evaluate(&program, &scope), json!(true)); diff --git a/crates/tinyloops/src/observe/mod.rs b/crates/tinyloops/src/observe/mod.rs index 5a66318..52c6ff0 100644 --- a/crates/tinyloops/src/observe/mod.rs +++ b/crates/tinyloops/src/observe/mod.rs @@ -117,6 +117,15 @@ pub fn render(event: &Event) -> String { Event::NoteDropped { from, capacity, .. } => { format!("pass {pass} note from {from} dropped, mailbox full at {capacity}") } + Event::Amended { + revision, + change, + because, + .. + } => format!("pass {pass} amended to r{revision}: {change} — {because}"), + Event::AmendmentRefused { change, reason, .. } => { + format!("pass {pass} refused {change}: {reason}") + } Event::ArmStarted { arm, .. } => format!("pass {pass} arm {arm} started"), Event::ArmFinished { arm, duration, .. } => { format!("pass {pass} arm {arm} finished in {duration:?}") diff --git a/crates/tinyloops/src/observe/test.rs b/crates/tinyloops/src/observe/test.rs index 7de48de..645dbea 100644 --- a/crates/tinyloops/src/observe/test.rs +++ b/crates/tinyloops/src/observe/test.rs @@ -655,6 +655,13 @@ fn the_line_sink_renders_one_line_per_event() { /// One of every event, so the rendering and the wire form are exercised whole /// rather than variant by variant as somebody remembers to. fn every_event() -> Vec { + let mut events = loop_events(); + events.extend(host_events()); + events +} + +/// The events the loop itself emits. +fn loop_events() -> Vec { vec![ Event::PassStarted { pass: 1 }, Event::PassFinished { @@ -713,6 +720,41 @@ fn every_event() -> Vec { pass: 1, directive: "stop after this pass".to_string(), }, + // Missing from this list until the amendment events were added, even + // though the variant has existed, been rendered, and been emitted from + // the mailbox the whole time — so nothing pinned its wire form. The + // count assertion below is what stops the next omission being as quiet. + Event::NoteDropped { + pass: 1, + from: "watcher".to_string(), + capacity: 8, + }, + Event::Amended { + pass: 1, + revision: 1, + change: crate::Change::Threshold { + field: crate::ThresholdField::Stuck, + to: 3, + }, + because: "diversifying did not pay".to_string(), + }, + Event::AmendmentRefused { + pass: 1, + change: crate::Change::Threshold { + field: crate::ThresholdField::MaxAttempts, + to: 99, + }, + reason: "max_attempts may be 4..=12, not 99".to_string(), + }, + ] +} + +/// The events the host and the engine emit around it. +/// +/// Split from [`loop_events`] only because one list of twenty-three is +/// longer than the lint allows; the two are always used together. +fn host_events() -> Vec { + vec![ Event::BoundTripped { pass: 1, bound: crate::Bound::RunClock, @@ -752,6 +794,19 @@ fn every_event() -> Vec { ] } +#[test] +fn the_fixture_holds_one_of_every_event() { + // The three tests below only prove what the fixture contains. A variant + // missing from it escapes all of them silently, which is exactly what + // happened to `NoteDropped`. Counting is the cheap guard: the number has to + // be edited in the same change that adds a variant, and the edit is where + // somebody notices the fixture entry is missing. + assert_eq!(every_event().len(), 23); + + let kinds: std::collections::BTreeSet<&str> = every_event().iter().map(Event::kind).collect(); + assert_eq!(kinds.len(), every_event().len(), "a kind appears twice"); +} + #[test] fn every_event_names_its_pass_and_renders_to_one_line() { for event in every_event() { @@ -802,6 +857,8 @@ fn only_the_spine_events_are_spine_events() { | "routed" | "bound_tripped" | "loop_finished" + | "amended" + | "amendment_refused" ); assert_eq!(event.is_spine(), expected, "{}", event.kind()); } diff --git a/crates/tinyloops/src/observe/types.rs b/crates/tinyloops/src/observe/types.rs index 861c657..75967a4 100644 --- a/crates/tinyloops/src/observe/types.rs +++ b/crates/tinyloops/src/observe/types.rs @@ -12,7 +12,7 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; use crate::budget::Bound; -use crate::policy::{Judgement, Outcome, Route}; +use crate::policy::{Change, Judgement, Outcome, Route}; use crate::state::Delta; /// What a run captures of the text flowing through it. @@ -360,6 +360,35 @@ pub enum Event { /// The capacity it did not fit in. capacity: usize, }, + /// The run revised its own configuration. + /// + /// A run that quietly retuned itself and then succeeded is + /// indistinguishable in its report from a run that succeeded as + /// configured. This is what separates them. + Amended { + /// The pass whose `pass` step folded it. It was proposed on that pass + /// and takes effect on the next. + pass: u32, + /// The profile revision this fold produced. + revision: u32, + /// What moved. + change: Change, + /// The evidence the proposer gave. + because: String, + }, + /// The run proposed a revision its bounds refused. + /// + /// Recorded as loudly as an acceptance. A tuner proposing forty refused + /// amendments is a broken tuner, and without this it reports nothing at + /// all. + AmendmentRefused { + /// The pass that proposed it. + pass: u32, + /// What it wanted to move. + change: Change, + /// Why it was refused. + reason: String, + }, /// A budget bound tripped. BoundTripped { /// The pass it belongs to. @@ -439,6 +468,8 @@ impl Event { | Self::ArmFinished { pass, .. } | Self::Merged { pass, .. } | Self::NoteDropped { pass, .. } + | Self::Amended { pass, .. } + | Self::AmendmentRefused { pass, .. } | Self::Judged { pass, .. } | Self::Routed { pass, .. } | Self::Delegated { pass, .. } @@ -472,6 +503,8 @@ impl Event { Self::ArmFinished { .. } => "arm_finished", Self::Merged { .. } => "merged", Self::NoteDropped { .. } => "note_dropped", + Self::Amended { .. } => "amended", + Self::AmendmentRefused { .. } => "amendment_refused", Self::Judged { .. } => "judged", Self::Routed { .. } => "routed", Self::Delegated { .. } => "delegated", @@ -506,6 +539,8 @@ impl Event { | Self::Routed { .. } | Self::BoundTripped { .. } | Self::LoopFinished { .. } + | Self::Amended { .. } + | Self::AmendmentRefused { .. } ) } } diff --git a/crates/tinyloops/src/orchestrate/steps.rs b/crates/tinyloops/src/orchestrate/steps.rs index 649e4c5..db8fd05 100644 --- a/crates/tinyloops/src/orchestrate/steps.rs +++ b/crates/tinyloops/src/orchestrate/steps.rs @@ -385,7 +385,7 @@ impl Step for Attempt { // Drained first, so a directive posted since the last pass shapes this // pass's briefs rather than the next one's. let directives = self.mailbox.collect(); - let route = crate::policy::route(&state, ctx.thresholds()); + let route = crate::policy::route(&state); let briefs = self.briefs(&state, route, &directives); for (_, brief) in &briefs { @@ -505,6 +505,20 @@ impl Compose for Summarize { let _ = writeln!(out, "- {lesson}"); } } + // Refusals are rendered beside acceptances. A report that showed only + // the revisions that landed would read as a run that was tuned exactly + // as much as it needed to be, whatever its tuner actually did. + if !state.profile.history.is_empty() { + let _ = write!( + out, + "\nRevised itself {} time(s), at r{}:\n", + state.profile.applied(), + state.profile.revision, + ); + for recorded in &state.profile.history { + let _ = writeln!(out, "- {recorded}"); + } + } Ok(out) } } diff --git a/crates/tinyloops/src/policy/amendment.rs b/crates/tinyloops/src/policy/amendment.rs new file mode 100644 index 0000000..4afc262 --- /dev/null +++ b/crates/tinyloops/src/policy/amendment.rs @@ -0,0 +1,381 @@ +//! What a run may change about itself, and the record of having changed it. +//! +//! An [`Amendment`] is one proposed move of one field of the run's +//! [`LoopProfile`](super::LoopProfile), carrying the evidence for it. It is the +//! only way a profile moves. +//! +//! # Why [`Change`] is a closed enum and not a patch +//! +//! A JSON patch, or anything else that addresses the accumulator by path, can +//! reach the counters the routing ladder reads. A tuner able to emit one is a +//! tuner able to write `solved`, and the loop would have no way to tell a +//! configuration change from a claim about the work. The variants here are the +//! whole vocabulary, and adding to it is a deliberate edit rather than a +//! consequence of a proposer getting more expressive. +//! +//! There is no variant for the re-plan cadence, because there does not need to +//! be: `plan_interval` is a [`Thresholds`](super::Thresholds) field like any +//! other, so [`ThresholdField::PlanInterval`] already names it. +//! +//! # Why every change is total +//! +//! [`Change::apply_to`] cannot fail. Validation happens once, in +//! [`Bounds::check`](super::Bounds), before anything is applied — so a proposal +//! is either refused whole, with its reason recorded, or applied whole. A change +//! that could half-apply would leave a profile nobody chose and no event +//! describing it. + +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +use super::{LoopProfile, Thresholds}; +use crate::budget::Caps; + +/// A field of [`Thresholds`] an amendment can move. +/// +/// One variant per field, so a proposal names a field rather than a path. The +/// wire names are the field names, which is what lets a reader of a run's events +/// match an amendment to the threshold it moved without a lookup table. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ThresholdField { + /// [`Thresholds::max_attempts`]. + MaxAttempts, + /// [`Thresholds::stuck`]. + Stuck, + /// [`Thresholds::blocked`]. + Blocked, + /// [`Thresholds::computational`]. + Computational, + /// [`Thresholds::unverified`]. + Unverified, + /// [`Thresholds::max_restarts`]. + MaxRestarts, + /// [`Thresholds::plan_interval`]. + PlanInterval, +} + +impl ThresholdField { + /// Every field, in declaration order. + pub const ALL: [Self; 7] = [ + Self::MaxAttempts, + Self::Stuck, + Self::Blocked, + Self::Computational, + Self::Unverified, + Self::MaxRestarts, + Self::PlanInterval, + ]; + + /// The field's name, as it appears on the wire and in an event. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::MaxAttempts => "max_attempts", + Self::Stuck => "stuck", + Self::Blocked => "blocked", + Self::Computational => "computational", + Self::Unverified => "unverified", + Self::MaxRestarts => "max_restarts", + Self::PlanInterval => "plan_interval", + } + } + + /// Reads this field out of `thresholds`. + #[must_use] + pub const fn read(self, thresholds: &Thresholds) -> u32 { + match self { + Self::MaxAttempts => thresholds.max_attempts, + Self::Stuck => thresholds.stuck, + Self::Blocked => thresholds.blocked, + Self::Computational => thresholds.computational, + Self::Unverified => thresholds.unverified, + Self::MaxRestarts => thresholds.max_restarts, + Self::PlanInterval => thresholds.plan_interval, + } + } + + /// Writes `value` into this field of `thresholds`. + pub const fn write(self, thresholds: &mut Thresholds, value: u32) { + match self { + Self::MaxAttempts => thresholds.max_attempts = value, + Self::Stuck => thresholds.stuck = value, + Self::Blocked => thresholds.blocked = value, + Self::Computational => thresholds.computational = value, + Self::Unverified => thresholds.unverified = value, + Self::MaxRestarts => thresholds.max_restarts = value, + Self::PlanInterval => thresholds.plan_interval = value, + } + } +} + +impl std::fmt::Display for ThresholdField { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A field of [`Caps`] an amendment can move. +/// +/// The clocks are absent, and deliberately: a run that could extend its own +/// wall clock has no wall clock. What is here is the counted work — calls, +/// tokens, retries — where lowering the ceiling is the useful move and raising +/// it is what [`Bounds`](super::Bounds) is for. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CapField { + /// [`Caps::max_model_calls`]. + MaxModelCalls, + /// [`Caps::max_tool_calls`]. + MaxToolCalls, + /// [`Caps::max_tokens`]. + MaxTokens, + /// [`Caps::max_retries`]. + MaxRetries, +} + +impl CapField { + /// Every field, in declaration order. + pub const ALL: [Self; 4] = [ + Self::MaxModelCalls, + Self::MaxToolCalls, + Self::MaxTokens, + Self::MaxRetries, + ]; + + /// The field's name, as it appears on the wire and in an event. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::MaxModelCalls => "max_model_calls", + Self::MaxToolCalls => "max_tool_calls", + Self::MaxTokens => "max_tokens", + Self::MaxRetries => "max_retries", + } + } + + /// Reads this field out of `caps`. + #[must_use] + pub const fn read(self, caps: &Caps) -> u64 { + match self { + Self::MaxModelCalls => caps.max_model_calls as u64, + Self::MaxToolCalls => caps.max_tool_calls as u64, + Self::MaxTokens => caps.max_tokens, + Self::MaxRetries => caps.max_retries as u64, + } + } + + /// Writes `value` into this field of `caps`, saturating at the field's own + /// width. + /// + /// Saturating rather than refusing, because the range check already + /// happened: [`Bounds::check`](super::Bounds) rejects anything above the + /// declared ceiling, and no declared ceiling can exceed the field it bounds. + pub fn write(self, caps: &mut Caps, value: u64) { + let narrowed = u32::try_from(value).unwrap_or(u32::MAX); + match self { + Self::MaxModelCalls => caps.max_model_calls = narrowed, + Self::MaxToolCalls => caps.max_tool_calls = narrowed, + Self::MaxTokens => caps.max_tokens = value, + Self::MaxRetries => caps.max_retries = narrowed, + } + } +} + +impl std::fmt::Display for CapField { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// One move of one field of a [`LoopProfile`]. +/// +/// # Wire form +/// +/// Internally tagged on `change`, so an event reads as +/// `{"change": "threshold", "field": "stuck", "to": 3}`. The tag and the field +/// names are a wire format: an amendment survives a checkpoint and is rendered +/// into a finished run's report. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "change", rename_all = "snake_case")] +pub enum Change { + /// Move a routing threshold. + Threshold { + /// Which threshold. + field: ThresholdField, + /// Its new value. + to: u32, + }, + /// Move a counted limit. + Cap { + /// Which limit. + field: CapField, + /// Its new value. + to: u64, + }, + /// Stop paying for a declared evaluation arm. + MuteArm { + /// The arm, which the run's `ArmSet` must already declare. + arm: String, + }, + /// Start paying for a muted arm again. + UnmuteArm { + /// The arm, which the run's `ArmSet` must already declare. + arm: String, + }, +} + +impl Change { + /// Applies this change to `profile`. + /// + /// Total: every variant lands. Whether it *should* land is + /// [`Bounds::check`](super::Bounds)'s question, asked before this is + /// called, so that a refused amendment leaves the profile untouched rather + /// than half-moved. + pub fn apply_to(&self, profile: &mut LoopProfile) { + match self { + Self::Threshold { field, to } => field.write(&mut profile.thresholds, *to), + Self::Cap { field, to } => field.write(&mut profile.caps, *to), + Self::MuteArm { arm } => { + profile.muted.insert(arm.clone()); + } + Self::UnmuteArm { arm } => { + profile.muted.remove(arm); + } + } + } + + /// The arm this change names, if it names one. + #[must_use] + pub fn arm(&self) -> Option<&str> { + match self { + Self::MuteArm { arm } | Self::UnmuteArm { arm } => Some(arm), + Self::Threshold { .. } | Self::Cap { .. } => None, + } + } +} + +impl std::fmt::Display for Change { + /// One line, as it appears in an event and in a run's report. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Threshold { field, to } => write!(f, "{field} := {to}"), + Self::Cap { field, to } => write!(f, "{field} := {to}"), + Self::MuteArm { arm } => write!(f, "mute {arm}"), + Self::UnmuteArm { arm } => write!(f, "unmute {arm}"), + } + } +} + +/// One proposed change, with who proposed it, when, and why. +/// +/// The `because` is not decoration. A run that quietly retuned itself and then +/// succeeded is indistinguishable in its report from a run that succeeded as +/// configured, so the evidence travels with the change and is rendered beside +/// it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Amendment { + /// The arm that proposed it. Exactly one arm in a set may. + pub proposer: String, + /// The pass it was proposed on. It takes effect on the next one. + pub pass: u32, + /// What it moves. + pub change: Change, + /// The evidence, in the proposer's words. + pub because: String, +} + +impl Amendment { + /// A proposal from `proposer` on `pass`. + /// + /// # Examples + /// + /// ``` + /// # use tinyloops::{Amendment, Change, ThresholdField}; + /// let amendment = Amendment::new( + /// "tune", + /// 3, + /// Change::Threshold { field: ThresholdField::Stuck, to: 3 }, + /// "diversifying made the run worse", + /// ); + /// assert_eq!(amendment.pass, 3); + /// assert_eq!(amendment.to_string(), "tune @3: stuck := 3 — diversifying made the run worse"); + /// ``` + #[must_use] + pub fn new( + proposer: impl Into, + pass: u32, + change: Change, + because: impl Into, + ) -> Self { + Self { + proposer: proposer.into(), + pass, + change, + because: because.into(), + } + } +} + +impl std::fmt::Display for Amendment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} @{}: {} — {}", + self.proposer, self.pass, self.change, self.because + ) + } +} + +/// The arms a profile is no longer paying for. +/// +/// A type alias rather than a newtype: it is a set of names, the ordering is +/// what makes a profile's wire form stable, and nothing about it needs +/// defending beyond that. +pub type Muted = BTreeSet; + +/// What became of a proposed amendment. +/// +/// Both outcomes are kept, and that is the point. A run that quietly retuned +/// itself and then succeeded is indistinguishable in its report from a run that +/// succeeded as configured; a tuner proposing forty refused amendments is a +/// broken tuner reporting nothing. Recording only the acceptances would hide +/// the second failure completely. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "verdict", rename_all = "snake_case")] +pub enum Verdict { + /// The amendment was folded into the profile. + Applied, + /// The amendment was refused, and the profile is untouched. + Refused { + /// Why, in the words of the check that refused it. + reason: String, + }, +} + +impl Verdict { + /// Whether the profile moved. + #[must_use] + pub const fn applied(&self) -> bool { + matches!(self, Self::Applied) + } +} + +/// One amendment and what became of it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Recorded { + /// What was proposed. + pub amendment: Amendment, + /// What became of it. + pub verdict: Verdict, +} + +impl std::fmt::Display for Recorded { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.verdict { + Verdict::Applied => write!(f, "{}", self.amendment), + Verdict::Refused { reason } => write!(f, "{} [refused: {reason}]", self.amendment), + } + } +} diff --git a/crates/tinyloops/src/policy/bounds.rs b/crates/tinyloops/src/policy/bounds.rs new file mode 100644 index 0000000..4f9d356 --- /dev/null +++ b/crates/tinyloops/src/policy/bounds.rs @@ -0,0 +1,278 @@ +//! The room a run has to revise itself, and the check that keeps it there. +//! +//! A tuner without bounds has one strategy available for every difficulty, +//! which is to raise whichever threshold is complaining. A run that can raise +//! `max_attempts` has no attempt ceiling; a run that can raise `stuck` never +//! diversifies; a run that can raise a cap has no budget. Each of those runs +//! completes, reports plausibly, and cost more than the run that was configured +//! correctly. +//! +//! [`Bounds`] is what makes that unavailable rather than discouraged. It is +//! separate from the proposer on purpose: a rule-based tuner and a model-based +//! one are bounded by the same value, so swapping one for the other cannot +//! widen what a run may do to itself. +//! +//! # Refused, never clamped +//! +//! [`Bounds::check`] returns an error rather than a nearest legal value. A +//! clamped proposal reads as accepted at the proposer and as a no-op in the +//! state, and nothing joins the two — so a tuner proposing the same impossible +//! change forty times looks, from every angle, like a tuner that is working. +//! The refusal is the signal. +//! +//! # The preset owns them; a deployment may narrow them +//! +//! [`Bounds`] ships with the preset, because the room a run has to revise +//! itself is part of the methodological bet the preset already states: choosing +//! a preset is choosing the bet *and* the room. [`Bounds::narrow`] lets a +//! deployment tighten one it distrusts, field by field, and cannot loosen one — +//! the same shape [`RunBudget::narrow`](crate::RunBudget::narrow) already has +//! for caps. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::amendment::{CapField, Change, ThresholdField}; +use crate::{Error, Result}; + +/// How many amendments a run may fold when nothing says otherwise. +/// +/// Four is a working budget and not a limit anyone should reach: a run that +/// wants a fifth revision of its own configuration is a run whose preset was +/// wrong, and the right repair is choosing a different preset rather than +/// arriving at one four moves at a time. +pub const DEFAULT_MAX_AMENDMENTS: u32 = 4; + +/// How many consecutive silent passes an arm gets before it may be muted. +pub const DEFAULT_MUTING_WINDOW: u32 = 3; + +/// An inclusive range one field may be moved within. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Range { + /// The lowest value an amendment may set. + pub low: u64, + /// The highest value an amendment may set. + pub high: u64, +} + +impl Range { + /// A range from `low` to `high`, inclusive. + /// + /// # Examples + /// + /// ``` + /// # use tinyloops::Range; + /// assert!(Range::new(1, 4).holds(4)); + /// assert!(!Range::new(1, 4).holds(5)); + /// ``` + #[must_use] + pub const fn new(low: u64, high: u64) -> Self { + Self { low, high } + } + + /// Whether `value` is inside the range. + #[must_use] + pub const fn holds(self, value: u64) -> bool { + value >= self.low && value <= self.high + } + + /// The tighter of two ranges. + /// + /// Raises the floor and lowers the ceiling, so narrowing can only ever + /// remove room. An inverted result — a floor above its ceiling — is a field + /// nothing may move, which is the honest reading of two bounds that do not + /// overlap. + #[must_use] + pub fn narrow(self, other: Self) -> Self { + Self { + low: self.low.max(other.low), + high: self.high.min(other.high), + } + } +} + +/// The room a run has to revise its own profile. +/// +/// A field with no entry cannot be moved at all. That is the safe default and +/// the deliberate one: a `Bounds` written without thinking about a field is a +/// `Bounds` that does not let a tuner touch it. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct Bounds { + /// The thresholds an amendment may move, and how far. + pub thresholds: BTreeMap, + /// The caps an amendment may move, and how far. + pub caps: BTreeMap, + /// The arms an amendment may mute or unmute. + pub mutable_arms: super::Muted, + /// Consecutive silent passes before an arm may be muted. + pub muting_window: u32, + /// How many amendments the whole run may fold. + pub max_amendments: u32, +} + +impl Bounds { + /// Bounds that permit nothing. + /// + /// The starting point for building one, and the right answer for a preset + /// that does not want to be tuned at all. + #[must_use] + pub fn none() -> Self { + Self { + muting_window: DEFAULT_MUTING_WINDOW, + max_amendments: 0, + ..Self::default() + } + } + + /// Lets an amendment move `field` within `range`. + #[must_use] + pub fn threshold(mut self, field: ThresholdField, range: Range) -> Self { + self.thresholds.insert(field, range); + self + } + + /// Lets an amendment move `field` within `range`. + #[must_use] + pub fn cap(mut self, field: CapField, range: Range) -> Self { + self.caps.insert(field, range); + self + } + + /// Lets an amendment mute and unmute `arm`. + #[must_use] + pub fn mutable(mut self, arm: impl Into) -> Self { + self.mutable_arms.insert(arm.into()); + self + } + + /// Sets how many amendments the run may fold. + #[must_use] + pub const fn amendments(mut self, max: u32) -> Self { + self.max_amendments = max; + self + } + + /// Sets how many silent passes an arm gets before it may be muted. + #[must_use] + pub const fn window(mut self, passes: u32) -> Self { + self.muting_window = passes; + self + } + + /// Whether `change` is inside these bounds. + /// + /// # Errors + /// + /// - [`Error::UnboundedAmendment`] when the field or arm has no entry at + /// all, which is how a `Bounds` that never mentioned a field refuses it. + /// - [`Error::AmendmentOutOfBounds`] when the field has a range and the + /// proposed value sits outside it. + /// + /// # Examples + /// + /// ``` + /// # use tinyloops::{Bounds, Change, Range, ThresholdField}; + /// let bounds = Bounds::none().threshold(ThresholdField::Stuck, Range::new(1, 4)); + /// + /// assert!(bounds.check(&Change::Threshold { field: ThresholdField::Stuck, to: 4 }).is_ok()); + /// assert!(bounds.check(&Change::Threshold { field: ThresholdField::Stuck, to: 5 }).is_err()); + /// ``` + pub fn check(&self, change: &Change) -> Result<()> { + match change { + Change::Threshold { field, to } => { + within(field.as_str(), self.thresholds.get(field), u64::from(*to)) + } + Change::Cap { field, to } => within(field.as_str(), self.caps.get(field), *to), + Change::MuteArm { arm } | Change::UnmuteArm { arm } => { + if self.mutable_arms.contains(arm) { + Ok(()) + } else { + Err(Error::UnboundedAmendment { field: arm.clone() }) + } + } + } + } + + /// The tighter of two bounds. + /// + /// Every field narrows: a range present in both is intersected, a range + /// present in only one is dropped, the mutable arms are intersected, and + /// both counts take the lower value. So a deployment can restrict a preset + /// it distrusts and cannot widen one, whatever it passes. + /// + /// # Examples + /// + /// ``` + /// # use tinyloops::{Bounds, Range, ThresholdField}; + /// let preset = Bounds::none() + /// .threshold(ThresholdField::Stuck, Range::new(1, 6)) + /// .amendments(4); + /// let deployment = Bounds::none() + /// .threshold(ThresholdField::Stuck, Range::new(2, 3)) + /// .amendments(9); + /// + /// let narrowed = preset.narrow(&deployment); + /// assert_eq!(narrowed.thresholds[&ThresholdField::Stuck], Range::new(2, 3)); + /// assert_eq!(narrowed.max_amendments, 4); + /// ``` + #[must_use] + pub fn narrow(&self, other: &Self) -> Self { + let thresholds = self + .thresholds + .iter() + .filter_map(|(field, range)| { + other + .thresholds + .get(field) + .map(|theirs| (*field, range.narrow(*theirs))) + }) + .collect(); + let caps = self + .caps + .iter() + .filter_map(|(field, range)| { + other + .caps + .get(field) + .map(|theirs| (*field, range.narrow(*theirs))) + }) + .collect(); + let mutable_arms = self + .mutable_arms + .intersection(&other.mutable_arms) + .cloned() + .collect(); + + Self { + thresholds, + caps, + mutable_arms, + muting_window: self.muting_window.max(other.muting_window), + max_amendments: self.max_amendments.min(other.max_amendments), + } + } +} + +/// The shared check behind the two numeric arms of [`Bounds::check`]. +/// +/// A missing range is a refusal rather than a permission: bounds that never +/// mention a field are bounds that do not let a tuner touch it. +fn within(field: &str, range: Option<&Range>, value: u64) -> Result<()> { + let Some(range) = range else { + return Err(Error::UnboundedAmendment { + field: field.to_owned(), + }); + }; + if range.holds(value) { + Ok(()) + } else { + Err(Error::AmendmentOutOfBounds { + field: field.to_owned(), + value, + low: range.low, + high: range.high, + }) + } +} diff --git a/crates/tinyloops/src/policy/ladder.rs b/crates/tinyloops/src/policy/ladder.rs index 1affc1b..e02cb00 100644 --- a/crates/tinyloops/src/policy/ladder.rs +++ b/crates/tinyloops/src/policy/ladder.rs @@ -6,15 +6,36 @@ //! //! # How they are kept in agreement //! -//! Every threshold is *interpolated* from the [`Thresholds`] passed in. Not one -//! number is typed into a program string. A literal `2` in the jq would be a -//! second copy of a constant, free to drift from the Rust the moment either is -//! tuned, and drift between a router and its ladder is invisible: both sides -//! still produce a route, they just produce different ones. +//! Neither program holds a threshold. Both *address* one, reading it out of the +//! accumulator at `.profile.thresholds.` — the same place +//! [`route`](super::route) reads it. A literal `2` in the jq would be a second +//! copy of a constant, free to drift from the Rust the moment either is tuned, +//! and drift between a router and its ladder is invisible: both sides still +//! produce a route, they just produce different ones. //! -//! The generated program emits exactly [`Route::as_str`], which is what +//! Rendering the number in, which is what these functions used to do, removed +//! that drift and bought a different problem: the emitted graph became a +//! function of the thresholds, so changing one changed the topology and a +//! resume across the change was refused. See +//! `docs/adr/0006-thresholds-addressed-from-run-state.md`. Addressing keeps the +//! single source and drops the coupling, and it means these are *constant* +//! programs — one ladder serves every preset and every revision of one. +//! +//! The program emits exactly [`Route::as_str`], which is what //! [`Route::parse`] reads back. //! +//! # The sentinel, and why it is not zero +//! +//! Every threshold read carries `// 4294967295`. That is `u32::MAX` standing +//! for "this state names no threshold", and it makes every rung of the ladder +//! false, so a state with no profile falls through to [`Route::Retry`] — the +//! cheap outcome every default in this crate points at. +//! +//! It cannot be left off. A missing key resolves to `null`, `null` sorts below +//! every number in jq, and `0 >= null` is therefore **true** — so an unguarded +//! read would fire the *first* rung and route [`Route::Blocked`] on a state +//! that simply had no profile, which is the most expensive answer available. +//! //! # Addressing the accumulator //! //! Both programs begin `(.state // .item) as $s`, which covers the two places @@ -40,39 +61,47 @@ use serde_json::{Value, json}; use tinyflows::expr; -use super::{Route, Thresholds}; +use super::Route; use crate::state::LoopState; use crate::{Error, Result}; +/// The value a threshold read falls back to when the state names none. +/// +/// `u32::MAX`, so every rung of the ladder is false and the run falls through +/// to [`Route::Retry`]. See the module docs for why the fallback cannot be +/// omitted and cannot be zero. +const NO_THRESHOLD: u32 = u32::MAX; + /// Returns the jq program that evaluates to a [`Route::as_str`] name. /// /// The arms are tested in the same order as [`route`](super::route), and for /// the same reasons; that documentation is the specification and this is its /// translation. /// +/// The program is a constant: it addresses the thresholds in the accumulator +/// rather than carrying them, so one ladder routes every preset and every +/// revision of one. +/// /// # Examples /// /// ``` -/// # use tinyloops::{Thresholds, ladder}; -/// let program = ladder(&Thresholds { blocked: 7, ..Thresholds::default() }); +/// # use tinyloops::ladder; +/// let program = ladder(); /// assert!(program.starts_with('=')); -/// assert!(program.contains(">= 7")); +/// assert!(program.contains(".profile.thresholds")); +/// assert!(!program.contains(">= 2")); /// ``` #[must_use] -pub fn ladder(thresholds: &Thresholds) -> String { +pub fn ladder() -> String { + let none = NO_THRESHOLD; format!( - "=(.state // .item) as $s \ -| if ((($s | .blocked) // 0) >= {blocked}) then \"{route_blocked}\" \ -elif ((($s | .solved) // false) or ((($s | .attempts) // 0) >= {max_attempts})) then \"{route_solved}\" \ -elif ((($s | .unverified) // 0) >= {unverified}) then \"{route_reported}\" \ -elif (((($s | .unproductive) // 0) >= {stuck}) or ((($s | .computational) // 0) >= {computational})) then \"{route_diversify}\" \ + "=(.state // .item) as $s | (($s | .profile.thresholds) // {{}}) as $t \ +| if ((($s | .blocked) // 0) >= (($t | .blocked) // {none})) then \"{route_blocked}\" \ +elif ((($s | .solved) // false) or ((($s | .attempts) // 0) >= (($t | .max_attempts) // {none}))) then \"{route_solved}\" \ +elif ((($s | .unverified) // 0) >= (($t | .unverified) // {none})) then \"{route_reported}\" \ +elif (((($s | .unproductive) // 0) >= (($t | .stuck) // {none})) or ((($s | .computational) // 0) >= (($t | .computational) // {none}))) then \"{route_diversify}\" \ else \"{route_retry}\" \ end", - blocked = thresholds.blocked, - max_attempts = thresholds.max_attempts, - unverified = thresholds.unverified, - stuck = thresholds.stuck, - computational = thresholds.computational, route_blocked = Route::Blocked.as_str(), route_solved = Route::Solved.as_str(), route_reported = Route::Reported.as_str(), @@ -89,28 +118,28 @@ end", /// to know *whether* the run stops, not which arm stopped it, and the /// disjunction is the same set of conditions with the ordering removed. /// +/// Like [`ladder`], a constant that addresses its thresholds. +/// /// # Examples /// /// ``` -/// # use tinyloops::{Thresholds, terminal_condition}; -/// let program = terminal_condition(&Thresholds::default()); +/// # use tinyloops::terminal_condition; +/// let program = terminal_condition(); /// assert!(program.starts_with('=')); -/// assert!(program.contains(">= 8")); +/// assert!(program.contains(".profile.thresholds")); +/// assert!(!program.contains(">= 8")); /// ``` #[must_use] -pub fn terminal_condition(thresholds: &Thresholds) -> String { +pub fn terminal_condition() -> String { + let none = NO_THRESHOLD; format!( - "=(.state // .item) as $s \ + "=(.state // .item) as $s | (($s | .profile.thresholds) // {{}}) as $t \ | ((($s | .expired) // false) \ -or ((($s | .restarts) // 0) >= {max_restarts}) \ +or ((($s | .restarts) // 0) >= (($t | .max_restarts) // {none})) \ or (($s | .solved) // false) \ -or ((($s | .attempts) // 0) >= {max_attempts}) \ -or ((($s | .blocked) // 0) >= {blocked}) \ -or ((($s | .unverified) // 0) >= {unverified}))", - max_restarts = thresholds.max_restarts, - max_attempts = thresholds.max_attempts, - blocked = thresholds.blocked, - unverified = thresholds.unverified, +or ((($s | .attempts) // 0) >= (($t | .max_attempts) // {none})) \ +or ((($s | .blocked) // 0) >= (($t | .blocked) // {none})) \ +or ((($s | .unverified) // 0) >= (($t | .unverified) // {none})))" ) } @@ -174,15 +203,15 @@ pub fn expr_scope(state: &LoopState, loop_id: &str) -> Value { /// # Examples /// /// ``` -/// # use tinyloops::{LoopState, Route, Thresholds, evaluate_ladder}; +/// # use tinyloops::{LoopState, Route, evaluate_ladder}; /// let mut state = LoopState::new("goal"); /// state.blocked = 2; -/// assert_eq!(evaluate_ladder(&state, "loop", &Thresholds::default())?, Route::Blocked); +/// assert_eq!(evaluate_ladder(&state, "loop")?, Route::Blocked); /// # Ok::<(), tinyloops::Error>(()) /// ``` -pub fn evaluate_ladder(state: &LoopState, loop_id: &str, thresholds: &Thresholds) -> Result { +pub fn evaluate_ladder(state: &LoopState, loop_id: &str) -> Result { let scope = expr_scope(state, loop_id); - let evaluated = expr::evaluate(&Value::String(ladder(thresholds)), &scope); + let evaluated = expr::evaluate(&Value::String(ladder()), &scope); evaluated .as_str() .map(Route::parse) @@ -199,19 +228,15 @@ pub fn evaluate_ladder(state: &LoopState, loop_id: &str, thresholds: &Thresholds /// # Examples /// /// ``` -/// # use tinyloops::{LoopState, Thresholds, evaluate_terminal_condition}; +/// # use tinyloops::{LoopState, evaluate_terminal_condition}; /// let mut state = LoopState::new("goal"); /// state.expired = true; -/// assert!(evaluate_terminal_condition(&state, "loop", &Thresholds::default())?); +/// assert!(evaluate_terminal_condition(&state, "loop")?); /// # Ok::<(), tinyloops::Error>(()) /// ``` -pub fn evaluate_terminal_condition( - state: &LoopState, - loop_id: &str, - thresholds: &Thresholds, -) -> Result { +pub fn evaluate_terminal_condition(state: &LoopState, loop_id: &str) -> Result { let scope = expr_scope(state, loop_id); - let evaluated = expr::evaluate(&Value::String(terminal_condition(thresholds)), &scope); + let evaluated = expr::evaluate(&Value::String(terminal_condition()), &scope); evaluated .as_bool() .ok_or(Error::TerminalConditionNotBoolean) diff --git a/crates/tinyloops/src/policy/mod.rs b/crates/tinyloops/src/policy/mod.rs index 9ae2d89..e6744ce 100644 --- a/crates/tinyloops/src/policy/mod.rs +++ b/crates/tinyloops/src/policy/mod.rs @@ -19,16 +19,27 @@ //! //! - `types.rs` — [`Thresholds`], [`Route`], [`Judgement`], [`Autonomy`], and //! [`Outcome`]: the numbers and the closed vocabularies. +//! - `profile.rs` — [`LoopProfile`]: the configuration one run operates under, +//! carried in its accumulator so the ladder can address it rather than have +//! it rendered in. +//! - `amendment.rs` — [`Amendment`] and [`Change`]: the one way a profile moves. +//! - `bounds.rs` — [`Bounds`]: how far it may move, owned by the preset. //! - `mod.rs` — [`route`] and [`is_terminal`]: the decision itself. //! - `ladder.rs` — [`ladder`] and [`terminal_condition`]: the same decision, //! emitted as the jq the graph runs. +mod amendment; +mod bounds; mod ladder; +mod profile; mod types; +pub use amendment::{Amendment, CapField, Change, Muted, Recorded, ThresholdField, Verdict}; +pub use bounds::{Bounds, DEFAULT_MAX_AMENDMENTS, DEFAULT_MUTING_WINDOW, Range}; pub use ladder::{ evaluate_ladder, evaluate_terminal_condition, expr_scope, ladder, terminal_condition, }; +pub use profile::LoopProfile; pub use types::{Autonomy, Judgement, Outcome, Route, Thresholds}; use crate::state::LoopState; @@ -63,21 +74,24 @@ use crate::state::LoopState; /// # Examples /// /// ``` -/// # use tinyloops::{LoopState, Route, Thresholds, route}; -/// let thresholds = Thresholds::default(); +/// # use tinyloops::{LoopState, Route, route}; +/// // The thresholds ride in the accumulator, at `state.profile.thresholds`, +/// // and `route` reads them from there. That is what keeps the graph's jq and +/// // this function reading one source. /// let mut state = LoopState::new("goal"); /// -/// assert_eq!(route(&state, &thresholds), Route::Retry); +/// assert_eq!(route(&state), Route::Retry); /// /// state.unproductive = 2; -/// assert_eq!(route(&state, &thresholds), Route::Diversify); +/// assert_eq!(route(&state), Route::Diversify); /// /// // Blocked outranks everything below it. /// state.blocked = 2; -/// assert_eq!(route(&state, &thresholds), Route::Blocked); +/// assert_eq!(route(&state), Route::Blocked); /// ``` #[must_use] -pub fn route(state: &LoopState, thresholds: &Thresholds) -> Route { +pub fn route(state: &LoopState) -> Route { + let thresholds = &state.profile.thresholds; if state.blocked >= thresholds.blocked { Route::Blocked } else if state.solved || state.attempts >= thresholds.max_attempts { @@ -105,19 +119,18 @@ pub fn route(state: &LoopState, thresholds: &Thresholds) -> Route { /// # Examples /// /// ``` -/// # use tinyloops::{LoopState, Thresholds, is_terminal}; -/// let thresholds = Thresholds::default(); +/// # use tinyloops::{LoopState, is_terminal}; /// let mut state = LoopState::new("goal"); -/// assert!(!is_terminal(&state, &thresholds)); +/// assert!(!is_terminal(&state)); /// /// state.expired = true; -/// assert!(is_terminal(&state, &thresholds)); +/// assert!(is_terminal(&state)); /// ``` #[must_use] -pub fn is_terminal(state: &LoopState, thresholds: &Thresholds) -> bool { +pub fn is_terminal(state: &LoopState) -> bool { state.expired - || state.restarts >= thresholds.max_restarts - || route(state, thresholds).is_terminal() + || state.restarts >= state.profile.thresholds.max_restarts + || route(state).is_terminal() } #[cfg(test)] diff --git a/crates/tinyloops/src/policy/profile.rs b/crates/tinyloops/src/policy/profile.rs new file mode 100644 index 0000000..7f73218 --- /dev/null +++ b/crates/tinyloops/src/policy/profile.rs @@ -0,0 +1,221 @@ +//! The configuration one run operates under, carried in its own accumulator. +//! +//! A [`LoopProfile`] holds the numbers the routing ladder reads. It lives in +//! the loop's accumulator rather than in the emitted graph, and that placement +//! is the whole point of the type. +//! +//! # Why the thresholds are state and not topology +//! +//! The ladder is jq the graph runs, so its thresholds have to reach it somehow. +//! Rendering them into the program — the original design — makes the emitted +//! graph a function of the thresholds, and +//! [`GraphSignature`](crate::GraphSignature) hashes each node's config whole. +//! A threshold change was therefore a *topology* change, and a resume across +//! one was refused. That is correct for a run whose configuration is fixed +//! before it starts and fatal to one that revises it: a run that retuned itself +//! at pass three recorded a signature describing a graph that no longer exists. +//! +//! Addressing them out of the accumulator keeps the half of the old rule that +//! was load-bearing — one source, never a literal typed into graph JSON — and +//! drops the half that was not. One graph now serves every preset and every +//! revision of every preset. See +//! `docs/adr/0006-thresholds-addressed-from-run-state.md`. +//! +//! # What a run may change about it +//! +//! Nothing, until a [`Tuner`](crate::Tuner) is wired in. When one is, it +//! proposes an [`Amendment`], the head folds it at the start of the next pass, +//! and [`LoopProfile::revision`] and [`LoopProfile::history`] record that it +//! did. What it may propose is [`Bounds`], which the preset owns. + +use serde::{Deserialize, Serialize}; + +use super::{Amendment, Bounds, Change, Muted, Recorded, Thresholds, Verdict}; +use crate::budget::{Caps, RunBudget}; +use crate::presets::Preset; + +/// The configuration a run is operating under. +/// +/// Seeded from a [`Preset`] at construction and carried in +/// [`LoopState::profile`](crate::LoopState::profile), where the routing ladder +/// and [`route`](crate::route) both read it from the same address. +/// +/// # Wire form +/// +/// `#[serde(default)]` at the container level, so an accumulator written by a +/// revision that lacked a field still deserializes and takes that field's +/// default. Field names *are* the wire format — the graph's jq addresses +/// `.profile.thresholds.` by name — so a rename is a decode error at +/// run time rather than a compile error. `src/policy/test.rs` pins the +/// representation for exactly that reason. +/// +/// # Examples +/// +/// ``` +/// # use tinyloops::{LoopProfile, Preset}; +/// let profile = LoopProfile::of(Preset::Persistent); +/// assert_eq!(profile.revision, 0); +/// assert_eq!(profile.thresholds.stuck, 4); +/// assert_eq!(profile.origin, Preset::Persistent); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct LoopProfile { + /// How many times this profile has been revised. + /// + /// Zero for a profile as constructed, and bumped by exactly one every time + /// an amendment is folded. + pub revision: u32, + /// The counter bounds the routing ladder reads. + pub thresholds: Thresholds, + /// The preset this profile started from. + /// + /// Kept so a finished run can say which bet it was making, and so a report + /// naming "the persistent preset" is reading a value rather than repeating + /// what a caller told it. + pub origin: Preset, + /// The counted limits an amendment may lower. + /// + /// A copy of the run's caps, carried here so a `Cap` amendment has + /// somewhere to land that survives a checkpoint. The [`RunBudget`] the + /// driver meters against is built from it, so lowering one here lowers what + /// the run may spend. + /// + /// [`RunBudget`]: crate::RunBudget + pub caps: Caps, + /// The declared arms this run has stopped paying for. + /// + /// A muted arm's node still runs and still converges — it returns + /// unchanged. Removing its edge would leave the merge barrier waiting on an + /// arm nothing will activate, which is a hung pass rather than a saved one. + pub muted: Muted, + /// Every amendment proposed against this profile, oldest first, with what + /// became of each. + /// + /// The run's own account of what it changed about itself and why, refusals + /// included. Rendered into the report, and the data a cross-run layer would + /// score — this crate scores nothing. + pub history: Vec, +} + +impl LoopProfile { + /// The profile a run starts on when it takes `preset`. + /// + /// # Examples + /// + /// ``` + /// # use tinyloops::{LoopProfile, Preset, Thresholds}; + /// assert_eq!(LoopProfile::of(Preset::Balanced).thresholds, Thresholds::default()); + /// ``` + #[must_use] + pub fn of(preset: Preset) -> Self { + Self { + revision: 0, + thresholds: preset.thresholds(), + origin: preset, + caps: Caps::default(), + muted: Muted::new(), + history: Vec::new(), + } + } + + /// Whether `arm` is one this run has stopped paying for. + #[must_use] + pub fn is_muted(&self, arm: &str) -> bool { + self.muted.contains(arm) + } + + /// How many amendments have actually moved this profile. + #[must_use] + pub fn applied(&self) -> usize { + self.history + .iter() + .filter(|recorded| recorded.verdict.applied()) + .count() + } + + /// Folds `amendment` in if `bounds` allow it, and records either way. + /// + /// Checks the run's amendment budget first and the field's range second, + /// then applies or refuses **whole**. A change that could half-apply would + /// leave a profile nobody chose and no record describing it. + /// + /// Out of range is refused, never clamped: a clamped proposal reads as + /// accepted at the proposer and as a no-op in the state, and nothing joins + /// the two. + /// + /// Returns the verdict, which the caller renders as an event. Applying here + /// rather than where the amendment was proposed is what makes "it takes + /// effect on the *next* pass" a property of the code's position rather than + /// a rule someone remembers: the caller is the `pass` step, the loop's + /// single exit and the only node that closes the cycle. + /// + /// # Examples + /// + /// ``` + /// # use tinyloops::{Amendment, Change, LoopProfile, Preset, ThresholdField}; + /// let bounds = Preset::Balanced.bounds(); + /// let mut profile = LoopProfile::of(Preset::Balanced); + /// + /// let verdict = profile.fold( + /// Amendment::new( + /// "tune", + /// 2, + /// Change::Threshold { field: ThresholdField::Stuck, to: 3 }, + /// "diversifying made the run worse", + /// ), + /// &bounds, + /// ); + /// + /// assert!(verdict.applied()); + /// assert_eq!(profile.thresholds.stuck, 3); + /// assert_eq!(profile.revision, 1); + /// assert_eq!(profile.history.len(), 1); + /// ``` + pub fn fold(&mut self, amendment: Amendment, bounds: &Bounds) -> Verdict { + let budget = usize::try_from(bounds.max_amendments).unwrap_or(usize::MAX); + let verdict = if self.applied() >= budget { + Verdict::Refused { + reason: format!("the run has folded its {budget} amendments"), + } + } else if let Err(error) = bounds.check(&amendment.change) { + Verdict::Refused { + reason: error.to_string(), + } + } else if let Err(error) = Self::would_break_budget(self.caps, &amendment.change) { + Verdict::Refused { + reason: error.to_string(), + } + } else { + amendment.change.apply_to(self); + self.revision = self.revision.saturating_add(1); + Verdict::Applied + }; + + self.history.push(Recorded { + amendment, + verdict: verdict.clone(), + }); + verdict + } + + /// Whether folding `change` onto `caps` would leave a `Caps` that + /// [`RunBudget::new`] refuses. + /// + /// [`Bounds::check`] validates one field against its own declared range, + /// which proves the moved number is in bounds and nothing about the + /// *combination*. A bound that permits `MaxToolCalls` down to 1 while + /// leaving `max_model_calls` untouched can accept a proposal that reads + /// back as applied and then makes `drive`'s next `RunBudget::narrow` fail + /// with [`Error::ContendedCaps`](crate::Error::ContendedCaps) instead of + /// refusing the proposal that caused it. Only a `Cap` change can break + /// this; every other variant leaves caps untouched, so it always passes. + fn would_break_budget(caps: Caps, change: &Change) -> crate::Result<()> { + if let Change::Cap { field, to } = change { + let mut candidate = caps; + field.write(&mut candidate, *to); + RunBudget::new(candidate)?; + } + Ok(()) + } +} diff --git a/crates/tinyloops/src/policy/test.rs b/crates/tinyloops/src/policy/test.rs index aa39f64..6c1906b 100644 --- a/crates/tinyloops/src/policy/test.rs +++ b/crates/tinyloops/src/policy/test.rs @@ -43,8 +43,8 @@ use serde_json::Value; use tinyflows::expr; use super::{ - Autonomy, Judgement, Outcome, Route, Thresholds, expr_scope, is_terminal, ladder, route, - terminal_condition, + Amendment, Autonomy, Bounds, CapField, Change, Judgement, LoopProfile, Outcome, Range, Route, + ThresholdField, Thresholds, expr_scope, is_terminal, ladder, route, terminal_condition, }; use crate::Error; use crate::state::LoopState; @@ -103,35 +103,47 @@ fn span(threshold: u32) -> usize { upto(threshold).count() } +/// A `state` routing under `thresholds`. +/// +/// Both sides read the thresholds out of the accumulator now, so a sweep sets +/// them there rather than passing them alongside. That is the property under +/// test as much as a convenience: a test that could hand the router one set and +/// the ladder another would be testing a configuration no run can reach. +fn under(state: LoopState, thresholds: Thresholds) -> LoopState { + LoopState { + profile: LoopProfile { + thresholds, + ..LoopProfile::default() + }, + ..state + } +} + /// Asserts the generated ladder and [`route`] agree about `state`. -fn assert_ladder_parity(state: &LoopState, thresholds: &Thresholds) { +fn assert_ladder_parity(state: &LoopState) { let scope = expr_scope(state, LOOP_ID); - let evaluated = expr::evaluate(&Value::String(ladder(thresholds)), &scope); - assert_ne!( - evaluated, - Value::Null, - "ladder produced null for {state:?} under {thresholds:?}" - ); + let evaluated = expr::evaluate(&Value::String(ladder()), &scope); + assert_ne!(evaluated, Value::Null, "ladder produced null for {state:?}"); assert_eq!( evaluated.as_str(), - Some(route(state, thresholds).as_str()), - "ladder and route disagree for {state:?} under {thresholds:?}" + Some(route(state).as_str()), + "ladder and route disagree for {state:?}" ); } /// Asserts the generated terminal condition and [`is_terminal`] agree. -fn assert_terminal_parity(state: &LoopState, thresholds: &Thresholds) { +fn assert_terminal_parity(state: &LoopState) { let scope = expr_scope(state, LOOP_ID); - let evaluated = expr::evaluate(&Value::String(terminal_condition(thresholds)), &scope); + let evaluated = expr::evaluate(&Value::String(terminal_condition()), &scope); assert_ne!( evaluated, Value::Null, - "terminal condition produced null for {state:?} under {thresholds:?}" + "terminal condition produced null for {state:?}" ); assert_eq!( evaluated.as_bool(), - Some(is_terminal(state, thresholds)), - "terminal condition and is_terminal disagree for {state:?} under {thresholds:?}" + Some(is_terminal(state)), + "terminal condition and is_terminal disagree for {state:?}" ); } @@ -168,7 +180,7 @@ fn the_ladder_agrees_with_route_on_every_combination() { solved, ..LoopState::new("sweep") }; - assert_ladder_parity(&state, &thresholds); + assert_ladder_parity(&under(state, thresholds)); swept += 1; } } @@ -217,7 +229,7 @@ fn the_terminal_condition_agrees_with_is_terminal_on_every_combination() { expired, ..LoopState::new("sweep") }; - assert_terminal_parity(&state, &thresholds); + assert_terminal_parity(&under(state, thresholds)); swept += 1; } } @@ -243,71 +255,128 @@ fn the_terminal_condition_agrees_with_is_terminal_on_every_combination() { } #[test] -fn the_ladder_interpolates_thresholds_rather_than_hard_coding_them() { - let thresholds = Thresholds { - blocked: 41, - max_attempts: 42, - unverified: 43, - stuck: 44, - computational: 45, - ..Thresholds::default() - }; - let program = ladder(&thresholds); +fn the_ladder_addresses_thresholds_rather_than_rendering_them() { + // The guard this replaces asserted the opposite — that every threshold was + // interpolated into the program. It is still the same class of failure + // being guarded against, a second copy of a constant free to drift, and the + // answer is now one address rather than one render. + let program = ladder(); - for number in [41, 42, 43, 44, 45] { + for field in [ + "blocked", + "max_attempts", + "unverified", + "stuck", + "computational", + ] { assert!( - program.contains(&format!(">= {number}")), - "{number} missing from {program}" + program.contains(&format!("$t | .{field}")), + "{field} is not read out of the profile: {program}" + ); + } + assert!(program.contains(".profile.thresholds"), "{program}"); + for rendered in [">= 8", ">= 2", ">= 1", ">= 4", ">= 12"] { + assert!( + !program.contains(rendered), + "{rendered} rendered: {program}" ); } - assert!(!ladder(&Thresholds::default()).contains(">= 41")); } #[test] -fn the_terminal_condition_interpolates_thresholds() { - let thresholds = Thresholds { - max_restarts: 51, - max_attempts: 52, - blocked: 53, - unverified: 54, - ..Thresholds::default() - }; - let program = terminal_condition(&thresholds); +fn the_terminal_condition_addresses_thresholds_rather_than_rendering_them() { + let program = terminal_condition(); - for number in [51, 52, 53, 54] { + for field in ["max_restarts", "max_attempts", "blocked", "unverified"] { + assert!( + program.contains(&format!("$t | .{field}")), + "{field} is not read out of the profile: {program}" + ); + } + for rendered in [">= 8", ">= 2", ">= 1", ">= 4", ">= 12"] { assert!( - program.contains(&format!(">= {number}")), - "{number} missing from {program}" + !program.contains(rendered), + "{rendered} rendered: {program}" ); } } +#[test] +fn a_ladder_reads_the_thresholds_out_of_the_accumulator() { + // Two states differing only in a threshold route differently through the + // *same* program. This is the whole change, stated as one assertion. + let mut state = LoopState::new("goal"); + state.unproductive = 2; + + let patient = under( + state.clone(), + Thresholds { + stuck: 4, + ..Thresholds::default() + }, + ); + let impatient = under( + state, + Thresholds { + stuck: 1, + ..Thresholds::default() + }, + ); + + let program = Value::String(ladder()); + assert_eq!( + expr::evaluate(&program, &expr_scope(&patient, LOOP_ID)).as_str(), + Some("retry") + ); + assert_eq!( + expr::evaluate(&program, &expr_scope(&impatient, LOOP_ID)).as_str(), + Some("diversify") + ); +} + +#[test] +fn a_state_with_no_profile_routes_retry() { + // A missing key is `null`, `null` sorts below every number in jq, and + // `0 >= null` is *true* — so an unguarded read would fire the first rung + // and route `blocked` on a state that simply had no profile. The sentinel + // is what points the default at the cheap outcome instead. + let evaluated = expr::evaluate( + &Value::String(ladder()), + &serde_json::json!({ "item": { "blocked": 0, "attempts": 0 } }), + ); + assert_eq!(evaluated.as_str(), Some("retry")); + + let terminal = expr::evaluate( + &Value::String(terminal_condition()), + &serde_json::json!({ "item": { "blocked": 0, "attempts": 0 } }), + ); + assert_eq!(terminal.as_bool(), Some(false)); +} + #[test] fn a_ladder_reads_the_accumulator_from_the_loop_head_state() { // The `until` position: the engine adds the post-fold accumulator as // `state`, and there is no `item`. - let thresholds = Thresholds::default(); let state = LoopState { blocked: 2, ..LoopState::new("goal") }; let scope = serde_json::json!({ "state": serde_json::to_value(&state).unwrap() }); - let evaluated = expr::evaluate(&Value::String(ladder(&thresholds)), &scope); + let evaluated = expr::evaluate(&Value::String(ladder()), &scope); assert_eq!(evaluated.as_str(), Some("blocked")); } #[test] fn a_ladder_reads_the_accumulator_from_the_previous_step() { // The downstream position: the accumulator arrives as the node's input. - let thresholds = Thresholds::default(); let state = LoopState { unverified: 2, ..LoopState::new("goal") }; let scope = serde_json::json!({ "item": serde_json::to_value(&state).unwrap() }); - let evaluated = expr::evaluate(&Value::String(ladder(&thresholds)), &scope); + let evaluated = expr::evaluate(&Value::String(ladder()), &scope); assert_eq!(evaluated.as_str(), Some("reported")); } @@ -315,17 +384,14 @@ fn a_ladder_reads_the_accumulator_from_the_previous_step() { fn an_empty_accumulator_still_routes() { // A loop whose accumulator has not been seeded yet must produce the cheap // route rather than null. - let evaluated = expr::evaluate( - &Value::String(ladder(&Thresholds::default())), - &serde_json::json!({ "item": {} }), - ); + let evaluated = expr::evaluate(&Value::String(ladder()), &serde_json::json!({ "item": {} })); assert_eq!(evaluated.as_str(), Some("retry")); } #[test] fn retries_a_run_that_has_done_nothing_notable() { let state = LoopState::new("goal"); - assert_eq!(route(&state, &Thresholds::default()), Route::Retry); + assert_eq!(route(&state), Route::Retry); } #[test] @@ -334,7 +400,7 @@ fn diversifies_after_two_unproductive_passes() { unproductive: 2, ..LoopState::new("goal") }; - assert_eq!(route(&state, &Thresholds::default()), Route::Diversify); + assert_eq!(route(&state), Route::Diversify); } #[test] @@ -343,7 +409,7 @@ fn diversifies_after_two_computational_passes() { computational: 2, ..LoopState::new("goal") }; - assert_eq!(route(&state, &Thresholds::default()), Route::Diversify); + assert_eq!(route(&state), Route::Diversify); } #[test] @@ -352,7 +418,7 @@ fn reports_an_answer_only_one_route_reached() { unverified: 2, ..LoopState::new("goal") }; - assert_eq!(route(&state, &Thresholds::default()), Route::Reported); + assert_eq!(route(&state), Route::Reported); } #[test] @@ -361,7 +427,7 @@ fn solves_a_run_that_reached_an_answer() { solved: true, ..LoopState::new("goal") }; - assert_eq!(route(&state, &Thresholds::default()), Route::Solved); + assert_eq!(route(&state), Route::Solved); } #[test] @@ -370,7 +436,7 @@ fn solves_a_run_that_spent_its_attempts() { attempts: 8, ..LoopState::new("goal") }; - assert_eq!(route(&state, &Thresholds::default()), Route::Solved); + assert_eq!(route(&state), Route::Solved); } #[test] @@ -379,7 +445,7 @@ fn blocks_a_run_whose_machinery_kept_failing() { blocked: 2, ..LoopState::new("goal") }; - assert_eq!(route(&state, &Thresholds::default()), Route::Blocked); + assert_eq!(route(&state), Route::Blocked); } #[test] @@ -390,7 +456,7 @@ fn blocked_outranks_solved() { attempts: 8, ..LoopState::new("goal") }; - assert_eq!(route(&state, &Thresholds::default()), Route::Blocked); + assert_eq!(route(&state), Route::Blocked); } #[test] @@ -401,7 +467,7 @@ fn reported_outranks_both_diversify_triggers() { computational: 2, ..LoopState::new("goal") }; - assert_eq!(route(&state, &Thresholds::default()), Route::Reported); + assert_eq!(route(&state), Route::Reported); } #[test] @@ -411,50 +477,44 @@ fn solved_outranks_reported() { unverified: 2, ..LoopState::new("goal") }; - assert_eq!(route(&state, &Thresholds::default()), Route::Solved); + assert_eq!(route(&state), Route::Solved); } #[test] fn a_run_out_of_restarts_is_terminal_without_being_a_route() { - let thresholds = Thresholds::default(); let state = LoopState { restarts: 2, ..LoopState::new("goal") }; - assert_eq!(route(&state, &thresholds), Route::Retry); - assert!(is_terminal(&state, &thresholds)); + assert_eq!(route(&state), Route::Retry); + assert!(is_terminal(&state)); } #[test] fn a_run_with_time_left_and_no_verdict_is_not_terminal() { - assert!(!is_terminal( - &LoopState::new("goal"), - &Thresholds::default() - )); + assert!(!is_terminal(&LoopState::new("goal"))); } #[test] fn evaluating_the_ladder_returns_the_same_route() { - let thresholds = Thresholds::default(); let state = LoopState { unproductive: 2, ..LoopState::new("goal") }; assert_eq!( - super::evaluate_ladder(&state, LOOP_ID, &thresholds).unwrap(), + super::evaluate_ladder(&state, LOOP_ID).unwrap(), Route::Diversify ); } #[test] fn evaluating_the_terminal_condition_returns_the_same_answer() { - let thresholds = Thresholds::default(); let state = LoopState { expired: true, ..LoopState::new("goal") }; - assert!(super::evaluate_terminal_condition(&state, LOOP_ID, &thresholds).unwrap()); + assert!(super::evaluate_terminal_condition(&state, LOOP_ID).unwrap()); } #[test] @@ -605,12 +665,8 @@ fn a_solved_run_that_banked_something_is_a_success() { banked: 1, ..LoopState::new("goal") }; - let thresholds = Thresholds::default(); - assert_eq!(Outcome::classify(&state, &thresholds), Outcome::Success); - assert_eq!( - Outcome::success(&state, &thresholds).unwrap(), - Outcome::Success - ); + assert_eq!(Outcome::classify(&state), Outcome::Success); + assert_eq!(Outcome::success(&state).unwrap(), Outcome::Success); } #[test] @@ -619,10 +675,7 @@ fn a_solved_run_that_banked_nothing_is_a_clean_no_op() { solved: true, ..LoopState::new("goal") }; - assert_eq!( - Outcome::classify(&state, &Thresholds::default()), - Outcome::CleanNoOp - ); + assert_eq!(Outcome::classify(&state), Outcome::CleanNoOp); } #[test] @@ -633,10 +686,9 @@ fn an_expired_run_is_never_a_success() { expired: true, ..LoopState::new("goal") }; - let thresholds = Thresholds::default(); - assert_eq!(Outcome::classify(&state, &thresholds), Outcome::Exhausted); + assert_eq!(Outcome::classify(&state), Outcome::Exhausted); assert_eq!( - Outcome::success(&state, &thresholds).unwrap_err(), + Outcome::success(&state).unwrap_err(), Error::UnearnedSuccess ); } @@ -649,10 +701,9 @@ fn a_run_out_of_attempts_is_never_a_success() { attempts: 8, ..LoopState::new("goal") }; - let thresholds = Thresholds::default(); - assert_eq!(Outcome::classify(&state, &thresholds), Outcome::Exhausted); + assert_eq!(Outcome::classify(&state), Outcome::Exhausted); assert_eq!( - Outcome::success(&state, &thresholds).unwrap_err(), + Outcome::success(&state).unwrap_err(), Error::UnearnedSuccess ); } @@ -665,20 +716,16 @@ fn a_blocked_run_is_never_a_success() { blocked: 2, ..LoopState::new("goal") }; - let thresholds = Thresholds::default(); - assert_eq!(Outcome::classify(&state, &thresholds), Outcome::Blocked); + assert_eq!(Outcome::classify(&state), Outcome::Blocked); assert_eq!( - Outcome::success(&state, &thresholds).unwrap_err(), + Outcome::success(&state).unwrap_err(), Error::UnearnedSuccess ); } #[test] fn an_unsolved_run_with_budget_left_is_stalled() { - assert_eq!( - Outcome::classify(&LoopState::new("goal"), &Thresholds::default()), - Outcome::Stalled - ); + assert_eq!(Outcome::classify(&LoopState::new("goal")), Outcome::Stalled); } #[test] @@ -692,3 +739,441 @@ fn outcomes_have_a_snake_case_wire_form() { Outcome::Exhausted ); } + +#[test] +fn a_default_profile_carries_the_balanced_thresholds() { + let profile = LoopProfile::default(); + assert_eq!(profile.revision, 0); + assert_eq!(profile.thresholds, Thresholds::default()); + assert_eq!(profile.origin, crate::presets::Preset::Balanced); + assert_eq!(LoopProfile::of(crate::presets::Preset::Balanced), profile); +} + +#[test] +fn a_profile_takes_the_thresholds_of_the_preset_it_names() { + for preset in crate::presets::Preset::ALL { + let profile = LoopProfile::of(preset); + assert_eq!(profile.thresholds, preset.thresholds()); + assert_eq!(profile.origin, preset); + assert_eq!(profile.revision, 0); + } +} + +#[test] +fn the_profile_wire_form_is_pinned() { + // The graph's jq addresses these names. A rename is a decode error at run + // time rather than a compile error, which is what this pins. + assert_eq!( + serde_json::to_value(LoopProfile::of(crate::presets::Preset::Persistent)).unwrap(), + serde_json::json!({ + "revision": 0, + "thresholds": { + "max_attempts": 12, + "stuck": 4, + "blocked": 2, + "computational": 2, + "unverified": 2, + "max_restarts": 2, + "plan_interval": 3, + }, + "origin": "persistent", + "caps": serde_json::to_value(crate::budget::Caps::default()).unwrap(), + "muted": [], + "history": [], + }) + ); +} + +#[test] +fn a_profile_written_without_a_revision_deserializes() { + // `serde(default)` at the container level: an accumulator written by a + // revision that lacked a field still decodes, taking that field's default. + let decoded: LoopProfile = + serde_json::from_value(serde_json::json!({ "origin": "cautious" })).unwrap(); + assert_eq!(decoded.revision, 0); + assert_eq!(decoded.thresholds, Thresholds::default()); + assert_eq!(decoded.origin, crate::presets::Preset::Cautious); + + let empty: LoopProfile = serde_json::from_value(serde_json::json!({})).unwrap(); + assert_eq!(empty, LoopProfile::default()); +} + +// --- amendments and bounds ------------------------------------------------- + +/// The change every test below starts from. +fn stuck_to(value: u32) -> Change { + Change::Threshold { + field: ThresholdField::Stuck, + to: value, + } +} + +#[test] +fn a_threshold_field_reads_and_writes_the_field_it_names() { + // The one place a field name and a struct field are joined by hand, so the + // round trip is asserted rather than assumed. A `Stuck` that wrote + // `blocked` would move a run's routing to a field nobody proposed. + for (index, field) in ThresholdField::ALL.into_iter().enumerate() { + let mut thresholds = Thresholds::default(); + let written = u32::try_from(index).unwrap() + 40; + field.write(&mut thresholds, written); + + assert_eq!(field.read(&thresholds), written); + for other in ThresholdField::ALL { + if other != field { + assert_eq!( + other.read(&thresholds), + other.read(&Thresholds::default()), + "{field} moved {other}", + ); + } + } + } +} + +#[test] +fn a_cap_field_reads_and_writes_the_field_it_names() { + for field in CapField::ALL { + let mut caps = crate::budget::Caps::default(); + field.write(&mut caps, 7); + assert_eq!(field.read(&caps), 7); + } +} + +#[test] +fn the_amendment_wire_form_is_pinned() { + let amendment = Amendment::new("tune", 3, stuck_to(3), "diversifying did not pay"); + + assert_eq!( + serde_json::to_value(&amendment).unwrap(), + serde_json::json!({ + "proposer": "tune", + "pass": 3, + "change": { "change": "threshold", "field": "stuck", "to": 3 }, + "because": "diversifying did not pay", + }) + ); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&amendment).unwrap()).unwrap(), + amendment, + ); +} + +#[test] +fn every_change_round_trips_and_renders() { + let changes = [ + stuck_to(3), + Change::Cap { + field: CapField::MaxTokens, + to: 1_000, + }, + Change::MuteArm { + arm: "judge".to_owned(), + }, + Change::UnmuteArm { + arm: "judge".to_owned(), + }, + ]; + + for change in changes { + let encoded = serde_json::to_value(&change).unwrap(); + assert_eq!(serde_json::from_value::(encoded).unwrap(), change); + assert!(!change.to_string().is_empty()); + } +} + +#[test] +fn a_change_names_the_arm_it_moves_and_nothing_else() { + assert_eq!( + Change::MuteArm { + arm: "judge".to_owned() + } + .arm(), + Some("judge") + ); + assert_eq!(stuck_to(3).arm(), None); +} + +#[test] +fn a_range_holds_its_endpoints_and_nothing_past_them() { + let range = Range::new(1, 4); + assert!(range.holds(1)); + assert!(range.holds(4)); + assert!(!range.holds(0)); + assert!(!range.holds(5)); +} + +#[test] +fn a_change_outside_its_range_is_refused_rather_than_clamped() { + let bounds = Bounds::none().threshold(ThresholdField::Stuck, Range::new(1, 4)); + + assert!(bounds.check(&stuck_to(4)).is_ok()); + assert_eq!( + bounds.check(&stuck_to(5)).unwrap_err(), + Error::AmendmentOutOfBounds { + field: "stuck".to_owned(), + value: 5, + low: 1, + high: 4, + }, + ); +} + +#[test] +fn a_field_with_no_bound_cannot_be_amended_at_all() { + // The safe default, and the deliberate one: bounds written without thinking + // about a field are bounds that do not let a tuner touch it. + let bounds = Bounds::none(); + assert_eq!( + bounds.check(&stuck_to(2)).unwrap_err(), + Error::UnboundedAmendment { + field: "stuck".to_owned(), + }, + ); + assert_eq!( + bounds + .check(&Change::MuteArm { + arm: "judge".to_owned() + }) + .unwrap_err(), + Error::UnboundedAmendment { + field: "judge".to_owned(), + }, + ); +} + +#[test] +fn narrowing_only_ever_tightens() { + let preset = Bounds::none() + .threshold(ThresholdField::Stuck, Range::new(1, 6)) + .threshold(ThresholdField::MaxAttempts, Range::new(4, 12)) + .cap(CapField::MaxTokens, Range::new(1, 1_000)) + .mutable("judge") + .mutable("reflect") + .amendments(4) + .window(2); + let deployment = Bounds::none() + .threshold(ThresholdField::Stuck, Range::new(2, 3)) + .cap(CapField::MaxTokens, Range::new(1, 9_999)) + .mutable("judge") + .amendments(99) + .window(9); + + let narrowed = preset.narrow(&deployment); + + // Intersected where both spoke. + assert_eq!( + narrowed.thresholds[&ThresholdField::Stuck], + Range::new(2, 3) + ); + assert_eq!(narrowed.caps[&CapField::MaxTokens], Range::new(1, 1_000)); + // Dropped where only one did: silence is not permission. + assert!( + !narrowed + .thresholds + .contains_key(&ThresholdField::MaxAttempts) + ); + assert_eq!(narrowed.mutable_arms.len(), 1); + // A deployment cannot buy the run more amendments, or a shorter window + // before an arm may be retired. + assert_eq!(narrowed.max_amendments, 4); + assert_eq!(narrowed.muting_window, 9); +} + +#[test] +fn folding_an_amendment_bumps_the_revision_and_records_it() { + let bounds = crate::presets::Preset::Balanced.bounds(); + let mut profile = LoopProfile::of(crate::presets::Preset::Balanced); + + let verdict = profile.fold(Amendment::new("tune", 1, stuck_to(3), "because"), &bounds); + + assert!(verdict.applied()); + assert_eq!(profile.thresholds.stuck, 3); + assert_eq!(profile.revision, 1); + assert_eq!(profile.applied(), 1); + assert_eq!(profile.history.len(), 1); + assert!(profile.history[0].to_string().contains("stuck := 3")); +} + +#[test] +fn a_refused_amendment_leaves_the_profile_byte_identical() { + let bounds = crate::presets::Preset::Balanced.bounds(); + let mut profile = LoopProfile::of(crate::presets::Preset::Balanced); + let before = profile.clone(); + + let verdict = profile.fold(Amendment::new("tune", 1, stuck_to(40), "because"), &bounds); + + assert!(!verdict.applied()); + assert_eq!(profile.thresholds, before.thresholds); + assert_eq!(profile.revision, 0); + assert_eq!(profile.applied(), 0); + // Recorded, though. A refusal nobody can see is a broken tuner nobody can + // see either. + assert_eq!(profile.history.len(), 1); + assert!(profile.history[0].to_string().contains("refused")); +} + +#[test] +fn a_cap_amendment_that_would_contend_the_budget_is_refused_rather_than_applied() { + // `MaxToolCalls` alone sits inside its declared range, but driving it down + // to 1 while `max_model_calls` stays at its default of 60 leaves a `Caps` + // `RunBudget::new` refuses — the tool cap can no longer be reached before + // the model-call cap. A per-field range check alone would read this as + // fine; folding must also ask whether the *combination* still budgets. + let bounds = Bounds::none() + .cap(CapField::MaxToolCalls, Range::new(1, 600)) + .amendments(1); + let mut profile = LoopProfile::of(crate::presets::Preset::Balanced); + let before = profile.clone(); + + let verdict = profile.fold( + Amendment::new( + "tune", + 1, + Change::Cap { + field: CapField::MaxToolCalls, + to: 1, + }, + "because", + ), + &bounds, + ); + + assert!(!verdict.applied()); + assert_eq!( + profile.caps, before.caps, + "a refused cap amendment leaves caps untouched" + ); + assert!( + profile.history[0].to_string().contains("reachable before"), + "{}", + profile.history[0] + ); +} + +#[test] +fn a_run_at_its_amendment_budget_refuses_the_next_and_carries_on() { + let bounds = Bounds::none() + .threshold(ThresholdField::Stuck, Range::new(1, 9)) + .amendments(2); + let mut profile = LoopProfile::of(crate::presets::Preset::Balanced); + + for value in [3, 4] { + assert!( + profile + .fold( + Amendment::new("tune", 1, stuck_to(value), "because"), + &bounds + ) + .applied() + ); + } + let third = profile.fold(Amendment::new("tune", 2, stuck_to(5), "because"), &bounds); + + assert!(!third.applied()); + assert_eq!( + profile.thresholds.stuck, 4, + "the profile stopped at the budget" + ); + assert_eq!(profile.revision, 2); + assert_eq!(profile.history.len(), 3); +} + +#[test] +fn muting_moves_the_profile_and_unmuting_moves_it_back() { + let bounds = Bounds::none().mutable("judge").amendments(2); + let mut profile = LoopProfile::of(crate::presets::Preset::Balanced); + + profile.fold( + Amendment::new( + "tune", + 1, + Change::MuteArm { + arm: "judge".to_owned(), + }, + "silent", + ), + &bounds, + ); + assert!(profile.is_muted("judge")); + + profile.fold( + Amendment::new( + "tune", + 2, + Change::UnmuteArm { + arm: "judge".to_owned(), + }, + "wanted again", + ), + &bounds, + ); + assert!(!profile.is_muted("judge")); +} + +#[test] +fn every_preset_states_its_bounds_and_none_of_them_permits_everything() { + for preset in crate::presets::Preset::ALL { + let bounds = preset.bounds(); + assert!(bounds.max_amendments > 0, "{preset} cannot revise itself"); + assert!( + !bounds.thresholds.is_empty(), + "{preset} bounds no threshold" + ); + // No preset may tune its way out of the ceiling that stops a run: the + // attempt cap can never be raised past the head's runaway backstop, or + // the amendment folds, reads back as raised, and buys nothing. + let backstop = u64::from(crate::budget::Caps::default().max_iterations); + if let Some(range) = bounds.thresholds.get(&ThresholdField::MaxAttempts) { + assert!( + range.high <= backstop, + "{preset} may amend past the backstop" + ); + } + } +} + +#[test] +fn every_field_and_verdict_renders_the_name_it_is_addressed_by() { + // The rendered names are what a reader of a run's events matches against + // the field that moved, so they are asserted rather than assumed to follow + // from the wire form. + for field in ThresholdField::ALL { + assert_eq!(field.to_string(), field.as_str()); + assert_eq!( + serde_json::to_value(field).unwrap(), + serde_json::Value::String(field.as_str().to_owned()), + ); + } + for field in CapField::ALL { + assert_eq!(field.to_string(), field.as_str()); + assert_eq!( + serde_json::to_value(field).unwrap(), + serde_json::Value::String(field.as_str().to_owned()), + ); + } + + assert_eq!( + Change::Cap { + field: CapField::MaxTokens, + to: 10, + } + .to_string(), + "max_tokens := 10", + ); + assert_eq!( + Change::UnmuteArm { + arm: "judge".to_owned(), + } + .to_string(), + "unmute judge", + ); + + assert!(crate::policy::Verdict::Applied.applied()); + assert!( + !crate::policy::Verdict::Refused { + reason: "no".to_owned(), + } + .applied() + ); +} diff --git a/crates/tinyloops/src/policy/types.rs b/crates/tinyloops/src/policy/types.rs index c4f80d0..a339772 100644 --- a/crates/tinyloops/src/policy/types.rs +++ b/crates/tinyloops/src/policy/types.rs @@ -294,17 +294,18 @@ impl Outcome { /// # Examples /// /// ``` - /// # use tinyloops::{LoopState, Outcome, Thresholds}; + /// # use tinyloops::{LoopState, Outcome}; /// let mut state = LoopState::new("goal"); /// state.solved = true; /// state.banked = 1; - /// assert_eq!(Outcome::classify(&state, &Thresholds::default()), Outcome::Success); + /// assert_eq!(Outcome::classify(&state), Outcome::Success); /// /// state.expired = true; - /// assert_eq!(Outcome::classify(&state, &Thresholds::default()), Outcome::Exhausted); + /// assert_eq!(Outcome::classify(&state), Outcome::Exhausted); /// ``` #[must_use] - pub fn classify(state: &LoopState, thresholds: &Thresholds) -> Self { + pub fn classify(state: &LoopState) -> Self { + let thresholds = &state.profile.thresholds; if state.blocked >= thresholds.blocked { Self::Blocked } else if state.expired || state.attempts >= thresholds.max_attempts { @@ -334,21 +335,21 @@ impl Outcome { /// # Examples /// /// ``` - /// # use tinyloops::{Error, LoopState, Outcome, Thresholds}; + /// # use tinyloops::{Error, LoopState, Outcome}; /// let mut state = LoopState::new("goal"); /// state.solved = true; /// state.banked = 1; - /// assert_eq!(Outcome::success(&state, &Thresholds::default())?, Outcome::Success); + /// assert_eq!(Outcome::success(&state)?, Outcome::Success); /// /// state.expired = true; /// assert_eq!( - /// Outcome::success(&state, &Thresholds::default()).unwrap_err(), + /// Outcome::success(&state).unwrap_err(), /// Error::UnearnedSuccess, /// ); /// # Ok::<(), tinyloops::Error>(()) /// ``` - pub fn success(state: &LoopState, thresholds: &Thresholds) -> Result { - match Self::classify(state, thresholds) { + pub fn success(state: &LoopState) -> Result { + match Self::classify(state) { Self::Success => Ok(Self::Success), _ => Err(Error::UnearnedSuccess), } diff --git a/crates/tinyloops/src/presets/README.md b/crates/tinyloops/src/presets/README.md index 21ac68f..a9ce7e3 100644 --- a/crates/tinyloops/src/presets/README.md +++ b/crates/tinyloops/src/presets/README.md @@ -1,110 +1,95 @@ -# `presets/` - -The batteries: threshold sets that say what they are betting, two evaluation -arms that keep a verdict mechanical, and a loop with every seam filled in. - -## Why the module exists - -Everything else in this crate is a part. A consumer handed only traits has to -make the two most consequential decisions in the design again, alone, and both -have a wrong answer that looks fine in a demo. - -## The public surface - -| Item | What it is | -| --- | --- | -| `Preset` | A named threshold set, with its bet in rustdoc. `Preset::ALL` is what the parity sweep iterates | -| `Reflect` | *Is the answer right?* The only arm that may end the run | -| `Judge` | *Was the pass conducted acceptably?* It corrects; it never concludes | -| `AssembledLoop`, `research_loop` | A loop that emits a graph and drives itself | -| `Driven` | How a driven run came out: the final accumulator, the outcome, the routes, the bound | -| `Gather`, `ArmStep`, `Advance`, `Converge` | The node bodies for the kernel nodes that are not the orchestrator's. `Converge` is the fold | - -## Design - -### The two anti-confabulation rules - -**A `solved` verdict needs three things at once**: the literal `SOLVED_MARKER` -in a reply, at least one artifact from the pass, and internal consistency — the -specialist that claimed it is the one that left something behind. Any one alone -is a claim. The conjunction is evidence. - -This is the one control a loop has over a verifier that is itself a model, whose -self-preference and position bias *grow* as the quality gap narrows, which is -precisely the regime a converging run is in. The third condition is the one a -naive "marker AND artifact" check misses, and it is tested separately for that -reason. - -**An unreadable verdict is the cheap outcome.** A judge that cannot parse the -report returns `Judgement::Proceed`, never `Restart`. The asymmetry is the whole -rule: reading a serialization slip as a restart throws away a run's work, which -is a far worse failure than one wasted pass. - -### Why the question is split in two - -`Reflect` answers whether the answer is right; `Judge` answers whether the pass -was conducted acceptably. An arm that can say "good work" and an arm that can -say "we are done" answering the same prompt is one arm with two names, and -`ArmSet` refuses a second concluding arm at construction for the same reason. - -Both arms read the typed `AttemptReport` as *input*, never as their own prior -assistant turn. Relabelling an identical erroneous claim away from the assistant -role raises the explicit correction rate by 23 to 93 percentage points across -most model and domain pairs. The fan-out shape makes that natural, and it must -not be optimised into a follow-up turn. - -### A preset carries its bet - -`stuck` is an estimator of the point where sequential revision stops beating -parallel sampling, and where that point sits depends entirely on how accurate a -domain's feedback is. `Persistent` bets that feedback is accurate enough to keep -revising; `Exploratory` bets it is not. A number with no rationale beside it is -a number nobody can argue with, revise, or tune per domain. - -`Preset::ALL` is the list `src/policy/test.rs` sweeps, so a preset cannot be -added without its generated jq ladder being proved against the Rust `route` -exhaustively over the bounded counter space. - -### Two ways to run, one routing - -`AssembledLoop::graph` emits the `WorkflowGraph` an engine runs. -`AssembledLoop::drive` runs the same loop in this process. They are not two -implementations of anything: `drive` invokes the registered steps with the -arguments the emitted nodes are addressed with, so both paths execute the same -bodies over the same values. The routing likewise resolves to `route` on both -sides, and the graph's ladder is generated from the same constants and proved -against that function. What differs is what owns the concurrency and the -durability. `tests/e2e.rs` asserts the two reach the same verdict. - -`drive` exists because the engine's mock capabilities are a dev-only dependency -here, so the shipped library cannot start a graph run, and because a loop you -can call from a test with no runtime, no scheduler, and no provider is the loop -most people should meet first. +# `presets` + +Assembled loops, ready to build or to drive, and the one shipped tuner. + +## What is here + +- [`research_loop`] — an orchestrator, two evaluation arms, and a preset. The + loop most callers should meet first. +- [`tuned_research_loop`] — the same loop with a third arm that may revise the + run's own configuration. +- `Preset` — the four shipped threshold sets, each stating the bet it makes and + the room it gives a run to revise that bet. +- `Rules` — the shipped tuner, a pure function of the counters. +- The node bodies the kernel graph reaches that are not the orchestrator's: + `Gather`, `ArmStep`, `Converge`, `Advance`. + +## The two loops are different loops + +`tuned_research_loop` is a separate function rather than a flag, because it +emits a third arm's node, a third pair of edges, and a different graph +signature. A run that can revise itself and a run that cannot should not be +told apart by an argument nobody reads in a diff. + +A loop assembled without a tuner costs nothing for the absent arm and proposes +nothing, and its report has no section about revisions. + +## What a run may revise, and what it may not + +The tuner proposes; `Preset::bounds` decides what may be proposed; the `pass` +step folds. Three constraints are worth stating operationally, because each is +the answer to a failure that is otherwise silent. + +**The bounds live outside the proposer.** Swapping the rule tuner for a model +one cannot widen what a run may do to itself. That is the whole reason +`Bounds` is a value on the preset rather than a property of the tuner. + +**A deployment may narrow, never widen.** `Bounds::narrow` clamps field by +field — the same operation `RunBudget::narrow` performs on caps — so a host +that distrusts a preset can tighten it and cannot loosen one, whatever it +passes. A field neither side mentions cannot be moved at all: silence is not +permission. + +**An amendment is refused, never clamped.** A clamped proposal reads as +accepted at the proposer and as a no-op in the state, and nothing joins the +two. Every refusal is recorded in `LoopProfile::history` and emitted as +`Event::AmendmentRefused`, so a tuner proposing forty impossible changes is +visible rather than merely ineffective. + +## The shipped tuner's rules + +Ordered, and the order is the policy: infrastructure first, because a run the +machinery is failing has learned nothing about its own patience; then patience; +then what the run is paying for and not reading. At most one proposal a pass. + +| Rule | Fires when | Proposes | +|---|---|---| +| blocked | `blocked` reaches one below its threshold | half the model-call allowance | +| patience | `unproductive` is strictly past `stuck` — a diversify already happened and the pass after it was unproductive too | `stuck + 1`, once | +| silence | the judge has returned the same score for the assembled `Bounds::muting_window` passes (`SILENT_SCORES` by default) | mute the judge | + +It is rule-based rather than model-based because a model asked mid-run whether +its own configuration is wrong has no ground truth to answer from and every +incentive to answer yes. A model tuner implements the same `Tuner` trait and is +bounded by the same `Bounds`. + +**The silence rule's window is configured, not hardcoded.** `Rules::new` +takes the window explicitly; `tuned_research_loop` wires it from the +preset's own `Bounds::muting_window`, so the rule that proposes a mute and the +`Advance` step that folds it agree on the same cadence. `Rules::default` +falls back to `SILENT_SCORES`, the shipped presets' declared window, for a +caller that wires the tuner up directly instead of going through +`tuned_research_loop`. + +The muting rule fires on **silence**, never on "scored worse". Eliminating the +weakest arm needs a measured reward per arm, and this loop has none — an arm +contributes a delta and a narrative, not a score of its own. ## Operational constraints -- **The merge folds in the graph.** The emitted `merge` node is addressed with - `state` (the attempt's output, the shared base) and `arms` (each arm's whole - returned accumulator, keyed by name), and `StepContext::arg` is how a body - reaches them. `Converge` calls `ArmSet::merge` — the same function `drive` - calls — so there is one fold, not two. -- An arm's narrative claim rides through the graph *as state*: `ArmStep` applies - the `Contribution` to the accumulator it returns and `Converge` reads it back - with `Contribution::claimed_from`. The two are inverses and are tested as - such. If they stop being inverses, a lesson or a steer silently stops reaching - the accumulator. -- An arm's output that is missing or `null` at the merge is an error, never a - smaller fold. Under this engine an expression that failed to resolve yields - `null`, so shrugging at it would turn a broken binding into a route taken on - evidence nobody gathered. -- A run stopped by a bound is never `Outcome::Success`, whatever its last pass - claimed. The classification is adjusted after the bound is known, which keeps - that rule in one place. -- `Advance` sets `passes` by assignment, never by increment: the fold is - at-least-once, so a replayed activation after a resume applies the update - twice. -- A threshold change changes the emitted topology and therefore the graph - signature, so an old checkpoint refuses to resume onto a retuned loop. - -See [`docs/specs/routing-and-policy.md`](../../../../docs/specs/routing-and-policy.md) -and [`docs/plans/observability-and-budget.md`](../../../../docs/plans/observability-and-budget.md). +- **A muted arm still runs its node and still converges**, returning unchanged. + Dropping its convergence edge would leave the merge barrier waiting on an arm + nothing will activate — a hung pass rather than a saved one. +- **An amendment never takes effect in the pass that proposed it.** The fold is + in `Advance`, the `pass` step, which is the loop's single exit and the only + node closing the cycle. That position is what makes the timing structural + rather than a rule someone remembers. +- **No preset may amend its attempt ceiling past the loop head's backstop.** + Above `Caps::max_iterations` the amendment folds, reads back as raised, and + buys nothing. `src/policy/test.rs` asserts the relationship. +- **The final profile is an output and nothing here scores it.** `Driven` + carries it with its full history. Scoring an amendment against outcomes spans + runs, and that is `tinyflows-adaptive`'s — see ADR 0003. + +[`research_loop`]: https://docs.rs/tinyloops +[`tuned_research_loop`]: https://docs.rs/tinyloops diff --git a/crates/tinyloops/src/presets/assembled.rs b/crates/tinyloops/src/presets/assembled.rs index 12b20b0..d91c260 100644 --- a/crates/tinyloops/src/presets/assembled.rs +++ b/crates/tinyloops/src/presets/assembled.rs @@ -34,13 +34,14 @@ use crate::orchestrate::{ Attempt, Decompose, DelegateSet, Orchestrator, Plan, Report as ReportStep, Specialists, Summarize, }; -use crate::policy::{Autonomy, Outcome, Route, Thresholds, route}; +use crate::policy::{Autonomy, LoopProfile, Outcome, Route, Verdict, route}; use crate::state::LoopState; -use crate::step::{STEP_ATTEMPT, STEP_PLAN, STEP_REPORT, STEP_RESEARCH, StepRegistry}; +use crate::step::{STEP_ATTEMPT, STEP_PASS, STEP_PLAN, STEP_REPORT, STEP_RESEARCH, StepRegistry}; use crate::tools::ToolGrant; use super::arms::{Judge, Reflect}; use super::steps::{Advance, ArmStep, Converge, Gather}; +use super::tuner::Rules; use super::types::Preset; /// How a driven run came out. @@ -53,6 +54,15 @@ use super::types::Preset; pub struct Driven { /// The accumulator as the last pass left it. pub state: LoopState, + /// The profile the run finished on, with every amendment it proposed and + /// what became of each. + /// + /// A copy of `state.profile`, surfaced because it is an *output*: it is + /// what a cross-run layer would score, and burying the run's account of + /// what it changed about itself one field deeper than its answer would + /// misstate how much it matters. Nothing in this crate scores it — that + /// boundary is ADR 0003. + pub profile: LoopProfile, /// How the run is classified. pub outcome: Outcome, /// The route each pass took, oldest first. @@ -77,7 +87,7 @@ impl Driven { pub struct AssembledLoop { goal: String, preset: Preset, - thresholds: Thresholds, + profile: LoopProfile, arms: ArmSet, registry: StepRegistry, budget: RunBudget, @@ -101,10 +111,19 @@ impl AssembledLoop { registry: StepRegistry, budget: RunBudget, ) -> Result { + // Seeded from the budget actually supplied, not from `Caps::default`: + // `drive` narrows this budget against `profile.caps` every pass, so a + // profile that started at the default would silently restrict a + // caller's wider budget back down, and a `Cap` amendment against a + // caller's narrower one would report itself applied without tightening + // anything. + let mut profile = LoopProfile::of(preset); + profile.caps = budget.caps(); + Ok(Self { goal: goal.into(), preset, - thresholds: preset.thresholds(), + profile, arms, registry, budget, @@ -128,6 +147,9 @@ impl AssembledLoop { /// should never silently move the other. #[must_use] pub fn with_budget(mut self, budget: RunBudget) -> Self { + // Kept in step with `Self::new`: `profile.caps` describes the budget + // actually in force, not the one the preset started from. + self.profile.caps = budget.caps(); self.budget = budget; self } @@ -145,10 +167,15 @@ impl AssembledLoop { self.preset } - /// The thresholds it routes on. + /// The profile it routes on. + /// + /// The thresholds live inside it, at `profile().thresholds`. They are one + /// value rather than two because the run carries them as one value: the + /// accumulator holds the profile, and the routing ladder addresses it + /// there. #[must_use] - pub fn thresholds(&self) -> &Thresholds { - &self.thresholds + pub fn profile(&self) -> &LoopProfile { + &self.profile } /// The closed step set its nodes run. @@ -175,8 +202,10 @@ impl AssembledLoop { /// Whatever [`LoopBuilder::build`] raises: an unknown step, an accumulator /// that will not serialize, or a graph the engine's validator rejects. pub fn graph(&self) -> Result { - LoopBuilder::new(self.thresholds, self.arms.clone(), self.registry.clone()) + LoopBuilder::new(self.arms.clone(), self.registry.clone()) .goal(self.goal.clone()) + .profile(self.profile.clone()) + .caps(self.budget.caps()) .autonomy(self.autonomy) .ids(self.ids) .name(format!("tinyloops::{}", self.preset)) @@ -185,9 +214,13 @@ impl AssembledLoop { /// The signature a checkpoint of this loop would carry. /// - /// Two assemblies of the same preset produce the same signature, and a - /// changed threshold produces a different one, which is what makes an - /// incompatible resume an error rather than silent corruption. + /// Two assemblies of the same preset produce the same signature, and so do + /// two assemblies of *different* presets: the thresholds are addressed out + /// of the accumulator rather than rendered into the graph, so they are not + /// topology and do not move the hash. What moves it is the shape — a node, + /// an edge, a port, an arm — which is what makes an incompatible resume an + /// error rather than silent corruption, and what lets a run that revised + /// its own thresholds resume from its own checkpoint. /// /// # Errors /// @@ -215,7 +248,7 @@ impl AssembledLoop { /// - [`Error::ContestedField`] when two arms claim the same narrative /// field, which is a wiring mistake with no correct resolution. pub fn drive(&self, recorder: &Recorder) -> Result { - let mut state = LoopState::new(self.goal.clone()); + let mut state = LoopState::with_profile(self.goal.clone(), self.profile.clone()); let mut meter = Meter::default(); let mut routes = Vec::new(); let mut bound = None; @@ -238,7 +271,7 @@ impl AssembledLoop { state = self.evaluate(&state, pass, recorder)?; - let chosen = route(&state, &self.thresholds); + let chosen = route(&state); routes.push(chosen); recorder.record(Event::Routed { pass, @@ -258,13 +291,40 @@ impl AssembledLoop { }); meter.pass(!state.last_attempt.is_empty()); - state.passes = pass.saturating_add(1); + + // The `pass` step, run rather than re-implemented. It counts the + // pass, clears the steer, and folds whatever amendment the tuner + // proposed — and a driver that inlined the first of those and + // skipped the other two would be a second, quieter loop. + let folded = state.profile.history.len(); + state = self.run_step(STEP_PASS, state, pass, recorder)?; + for recorded in &state.profile.history[folded..] { + recorder.record(match &recorded.verdict { + Verdict::Applied => Event::Amended { + pass, + revision: state.profile.revision, + change: recorded.amendment.change.clone(), + because: recorded.amendment.because.clone(), + }, + Verdict::Refused { reason } => Event::AmendmentRefused { + pass, + change: recorded.amendment.change.clone(), + reason: reason.clone(), + }, + }); + } + recorder.record(Event::PassFinished { pass, duration: Duration::ZERO, }); - if let Some(tripped) = self.budget.tripped(&meter) { + // Narrowed by the run's own caps, so a `Cap` amendment actually + // tightens what the run may spend. `narrow` takes the lower of the + // two field by field, so a run can restrict itself and can never + // spend past the budget its embedder set. + let budget = self.budget.narrow(state.profile.caps)?; + if let Some(tripped) = budget.tripped(&meter) { bound = Some(tripped); recorder.record(Event::BoundTripped { pass, @@ -272,7 +332,7 @@ impl AssembledLoop { }); break; } - if crate::policy::is_terminal(&state, &self.thresholds) { + if crate::policy::is_terminal(&state) { break; } } @@ -292,7 +352,7 @@ impl AssembledLoop { // claimed. `classify` reads `expired` and the attempt cap; an iteration // or a token cap has to be folded in here, and folding it in *after* // the classification is what keeps that rule in one place. - let classified = Outcome::classify(&state, &self.thresholds); + let classified = Outcome::classify(&state); let outcome = match bound { Some(tripped) if !tripped.is_graceful() => Outcome::Exhausted, Some(_) if classified == Outcome::Stalled => Outcome::Exhausted, @@ -304,6 +364,7 @@ impl AssembledLoop { }); Ok(Driven { + profile: state.profile.clone(), state, outcome, routes, @@ -328,7 +389,7 @@ impl AssembledLoop { pass, step: name.to_owned(), }); - let advanced = self.registry.run(name, state, &self.thresholds)?; + let advanced = self.registry.run(name, state)?; recorder.record(Event::StepFinished { pass, step: name.to_owned(), @@ -360,9 +421,7 @@ impl AssembledLoop { pass, arm: arm.name().to_owned(), }); - let candidate = self - .registry - .run(arm.name(), state.clone(), &self.thresholds)?; + let candidate = self.registry.run(arm.name(), state.clone())?; deltas.push(candidate.delta_from(state)); returned.insert( arm.name().to_owned(), @@ -404,7 +463,6 @@ impl AssembledLoop { self.registry.run_with( STEP_MERGE, state.clone(), - &self.thresholds, &json!({ "arms": Value::Object(returned) }), ) } @@ -461,6 +519,80 @@ pub fn research_loop( delegates: DelegateSet, decompose: Arc, specialists: Arc, +) -> Result { + assemble(goal, preset, delegates, decompose, specialists, None) +} + +/// The same loop, with the shipped rule tuner wired in as a third arm. +/// +/// A separate function rather than a flag on [`research_loop`], because it is a +/// different loop: it emits a third arm's node, a third pair of edges, and a +/// different graph signature. A run that can revise itself and a run that +/// cannot should not be told apart by an argument nobody reads in a diff. +/// +/// What it may revise is [`Preset::bounds`], which the preset owns. The tuner +/// itself is [`Rules`], a pure function of the counters. +/// +/// # Errors +/// +/// Whatever [`research_loop`] raises. +/// +/// # Examples +/// +/// ``` +/// # use std::sync::Arc; +/// # use tinyloops::{ +/// # DelegateSet, FixedPlan, Inline, Preset, Recorder, Scripted, tuned_research_loop, +/// # }; +/// let delegates = DelegateSet::of(["prover"]); +/// let assembled = tuned_research_loop( +/// "bound the error term", +/// Preset::Balanced, +/// delegates.clone(), +/// Arc::new(FixedPlan::of([("bound", "bound the error term", "a proved bound")])), +/// Arc::new(Inline::of( +/// delegates, +/// [("prover".to_owned(), vec![Scripted::Answers { +/// reply: "no luck".to_owned(), +/// artifacts: Vec::new(), +/// }])], +/// )), +/// )?; +/// +/// let sink = Arc::new(tinyloops::LineSink::new(std::io::sink())); +/// let driven = assembled.drive(&Recorder::new("run", sink))?; +/// +/// // Every revision it made to itself, and every one its bounds refused. +/// for recorded in &driven.profile.history { +/// println!("{recorded}"); +/// } +/// # Ok::<(), tinyloops::Error>(()) +/// ``` +pub fn tuned_research_loop( + goal: impl Into, + preset: Preset, + delegates: DelegateSet, + decompose: Arc, + specialists: Arc, +) -> Result { + assemble( + goal, + preset, + delegates, + decompose, + specialists, + Some(Arc::new(Rules::new(preset.bounds().muting_window))), + ) +} + +/// The assembly both shipped loops share. +fn assemble( + goal: impl Into, + preset: Preset, + delegates: DelegateSet, + decompose: Arc, + specialists: Arc, + tuner: Option>, ) -> Result { let delegates_for_research = delegates.clone(); let orchestrator = Orchestrator::new(ToolGrant::read_only(), delegates)?; @@ -484,9 +616,16 @@ pub fn research_loop( registry.register(Arc::new(Attempt::new(orchestrator, specialists, mailbox)))?; registry.register(Arc::new(ArmStep::new(Arc::clone(&reflect))))?; registry.register(Arc::new(ArmStep::new(Arc::clone(&judge))))?; - let arms = ArmSet::new(vec![reflect, judge])?; + + let mut declared: Vec> = vec![reflect, judge]; + if let Some(tuner) = tuner { + let tuning: Arc = Arc::new(crate::arm::TunerArm::new(tuner)); + registry.register(Arc::new(ArmStep::new(Arc::clone(&tuning))))?; + declared.push(tuning); + } + let arms = ArmSet::new(declared)?; registry.register(Arc::new(Converge::new(arms.clone())))?; - registry.register(Arc::new(Advance))?; + registry.register(Arc::new(Advance::new(preset.bounds(), &arms)?))?; registry.register(Arc::new(ReportStep::new(Arc::new(Summarize))))?; AssembledLoop::new(goal, preset, arms, registry, RunBudget::default()) diff --git a/crates/tinyloops/src/presets/mod.rs b/crates/tinyloops/src/presets/mod.rs index a13ccb9..ffe8d38 100644 --- a/crates/tinyloops/src/presets/mod.rs +++ b/crates/tinyloops/src/presets/mod.rs @@ -32,11 +32,13 @@ mod arms; mod assembled; mod steps; +mod tuner; mod types; pub use arms::{Judge, Reflect, SOLVED_MARKER}; -pub use assembled::{AssembledLoop, Driven, research_loop}; +pub use assembled::{AssembledLoop, Driven, research_loop, tuned_research_loop}; pub use steps::{Advance, ArmStep, Converge, Gather}; +pub use tuner::{Rules, SILENT_SCORES}; pub use types::Preset; #[cfg(test)] diff --git a/crates/tinyloops/src/presets/steps.rs b/crates/tinyloops/src/presets/steps.rs index 7556de8..9090518 100644 --- a/crates/tinyloops/src/presets/steps.rs +++ b/crates/tinyloops/src/presets/steps.rs @@ -12,6 +12,7 @@ use crate::arm::{Arm, ArmOutcome, ArmSet}; use crate::error::{Error, Result}; use crate::harness::{Brief, Ending}; use crate::orchestrate::{DelegateSet, Specialists}; +use crate::policy::Bounds; use crate::state::{Contribution, LoopState}; use crate::step::{Advanced, CanWrite, STEP_PASS, STEP_RESEARCH, Step, StepContext}; @@ -117,6 +118,16 @@ impl Step for ArmStep { } fn run(&self, state: LoopState, ctx: StepContext<'_, CanWrite>) -> Result { + // A muted arm's node still runs and still converges; what it does not + // do is the work. Dropping its edge instead would leave the merge + // barrier waiting on an arm nothing will activate — a hung pass rather + // than a saved one — and would make the fan-out and the fold settable + // independently, which is the drift the one-list rule exists to make + // unrepresentable. + if state.profile.is_muted(self.arm.name()) { + return Ok(ctx.advance(state)); + } + // An arm reads the attempt report, never the accumulator: the head // folds at the top of a pass, so mid-body the accumulator is one pass // behind and an arm reading it routes on a stale answer. @@ -147,8 +158,52 @@ impl Step for ArmStep { /// at-least-once: a replayed activation after a resume applies it twice, and /// `passes = n + 1` computed from a stale `n` is wrong in a way `passes += 1` /// is not visibly wrong. -#[derive(Debug, Clone, Copy, Default)] -pub struct Advance; +#[derive(Debug, Clone, Default)] +pub struct Advance { + bounds: Bounds, +} + +impl Advance { + /// A `pass` step that folds amendments within `bounds`, against `arms`. + /// + /// [`Advance::default`] carries [`Bounds::default`], which permits nothing: + /// a loop assembled without deciding what its run may revise is a loop that + /// revises nothing. That is the safe direction to get wrong. + /// + /// # Errors + /// + /// [`Error::IneligibleMutableArm`] when `bounds` names an arm mutable that + /// `arms` does not declare, or names the arm `arms` allows to conclude. + /// Both are caught here, at assembly, rather than left for a tuner's + /// amendment to discover: the first would spend the run's amendment + /// budget on a recorded no-op, and the second would let a run mute the + /// one arm able to end it. + pub fn new(bounds: Bounds, arms: &ArmSet) -> Result { + let concluding = arms.concluding(); + let declared = arms.names(); + for arm in &bounds.mutable_arms { + if Some(arm.as_str()) == concluding { + return Err(Error::IneligibleMutableArm { + arm: arm.clone(), + reason: "it is the run's concluding arm", + }); + } + if !declared.contains(&arm.as_str()) { + return Err(Error::IneligibleMutableArm { + arm: arm.clone(), + reason: "the run's arm set does not declare it", + }); + } + } + Ok(Self { bounds }) + } + + /// The room this step gives a proposal. + #[must_use] + pub fn bounds(&self) -> &Bounds { + &self.bounds + } +} impl Step for Advance { fn name(&self) -> &'static str { @@ -159,6 +214,16 @@ impl Step for Advance { let mut state = state; state.passes = ctx.pass().saturating_add(1); state.steer = String::new(); + + // The fold lands here, at the loop's single exit, and not in the arm + // that proposed it. An arm that could change a threshold and have the + // same pass's route read it would make the route depend on whether the + // tuner finished before the routing node — arrival order deciding the + // run. Taking the proposal also clears it, so one proposal lands once. + if let Some(amendment) = state.proposed.take() { + state.profile.fold(amendment, &self.bounds); + } + Ok(ctx.advance(state)) } } diff --git a/crates/tinyloops/src/presets/test.rs b/crates/tinyloops/src/presets/test.rs index adffdf7..ffbf2b6 100644 --- a/crates/tinyloops/src/presets/test.rs +++ b/crates/tinyloops/src/presets/test.rs @@ -13,7 +13,9 @@ use std::sync::Arc; use serde_json::{Value, json}; -use super::{AssembledLoop, Judge, Preset, Reflect, SOLVED_MARKER, research_loop}; +use super::{ + AssembledLoop, Judge, Preset, Reflect, Rules, SOLVED_MARKER, research_loop, tuned_research_loop, +}; use crate::arm::{Arm, ArmSet}; use crate::budget::{Bound, Caps, RunBudget}; use crate::error::Error; @@ -63,17 +65,15 @@ fn each_preset_deviates_from_the_default_in_the_field_its_bet_is_about() { fn the_persistence_bet_and_the_variation_bet_route_the_same_state_differently() { // The same run, one pass into a stall, read by two presets. This is the bet // made visible: exploratory diversifies, persistent keeps revising. - let mut state = LoopState::new("goal"); - state.unproductive = 1; - - assert_eq!( - crate::policy::route(&state, &Preset::Exploratory.thresholds()), - Route::Diversify - ); - assert_eq!( - crate::policy::route(&state, &Preset::Persistent.thresholds()), - Route::Retry - ); + let mut exploratory = + LoopState::with_profile("goal", crate::policy::LoopProfile::of(Preset::Exploratory)); + exploratory.unproductive = 1; + let mut persistent = + LoopState::with_profile("goal", crate::policy::LoopProfile::of(Preset::Persistent)); + persistent.unproductive = 1; + + assert_eq!(crate::policy::route(&exploratory), Route::Diversify); + assert_eq!(crate::policy::route(&persistent), Route::Retry); } #[test] @@ -97,11 +97,10 @@ fn the_presets_are_the_set_the_parity_sweep_reads() { assert_eq!(Preset::ALL.len(), 4); for preset in Preset::ALL { let thresholds = preset.thresholds(); - let mut state = LoopState::new("goal"); + let mut state = LoopState::with_profile("goal", crate::policy::LoopProfile::of(preset)); state.blocked = thresholds.blocked; - let rendered = - evaluate_ladder(&state, "loop", &thresholds).expect("the generated ladder evaluates"); + let rendered = evaluate_ladder(&state, "loop").expect("the generated ladder evaluates"); assert_eq!( rendered, Route::Blocked, @@ -134,7 +133,7 @@ fn an_assembled_loop_carries_its_preset_thresholds_and_budget() { let assembled = assembled(Preset::Cautious).expect("assembles"); assert_eq!(assembled.preset(), Preset::Cautious); - assert_eq!(assembled.thresholds().unverified, 1); + assert_eq!(assembled.profile().thresholds.unverified, 1); assert_eq!(assembled.budget().caps(), Caps::default()); } @@ -813,11 +812,11 @@ fn the_pass_step_counts_the_pass_by_assignment_and_consumes_the_steer() { let mut state = LoopState::new("goal"); state.steer = "narrow the claim".to_owned(); - let once = Advance + let once = Advance::default() .run(state, advancing(4, &thresholds)) .expect("pass runs") .into_state(); - let twice = Advance + let twice = Advance::default() .run(once.clone(), advancing(4, &thresholds)) .expect("pass runs again") .into_state(); @@ -825,7 +824,7 @@ fn the_pass_step_counts_the_pass_by_assignment_and_consumes_the_steer() { assert_eq!(once.passes, 5); assert_eq!(twice, once); assert!(once.steer.is_empty()); - assert_eq!(Advance.name(), crate::step::STEP_PASS); + assert_eq!(Advance::default().name(), crate::step::STEP_PASS); } #[test] @@ -1014,6 +1013,15 @@ fn applying_a_contribution_and_reading_it_back_is_the_identity() { score: Some(6), judged: Some(Judgement::Restart), last_attempt: Some("the report".to_owned()), + amendment: Some(crate::policy::Amendment::new( + "reflect", + 0, + crate::policy::Change::Threshold { + field: crate::policy::ThresholdField::Stuck, + to: 3, + }, + "the round trip has to carry it too", + )), }; let mut candidate = base.clone(); @@ -1085,3 +1093,494 @@ fn merge_of( ) .map(crate::step::Advanced::into_state) } + +#[test] +fn every_preset_can_reach_its_attempt_ceiling() { + // The loop head's `max_iterations` is a runaway backstop, and a backstop + // below a preset's own attempt ceiling truncates the run before its + // thresholds ever fire. `Preset::Persistent` asks for twelve attempts; a + // default cap of eight would stop it four short, and the run would report + // "not solved within N attempts" having never spent N. + let cap = crate::budget::Caps::default().max_iterations; + for preset in Preset::ALL { + let ceiling = preset.thresholds().max_attempts; + assert!( + ceiling <= cap, + "{preset} asks for {ceiling} attempts under a cap of {cap}", + ); + } +} + +#[test] +fn a_presets_wire_name_is_the_name_it_renders() { + // A preset travels inside the accumulator as part of the profile, so its + // serde names are a wire format. Two spellings of one variant would decode + // as an error somewhere nobody is looking. + for preset in Preset::ALL { + assert_eq!( + serde_json::to_value(preset).unwrap(), + serde_json::Value::String(preset.as_str().to_owned()), + ); + assert_eq!( + serde_json::from_value::(serde_json::json!(preset.as_str())).unwrap(), + preset, + ); + } + assert_eq!(Preset::default(), Preset::Balanced); +} + +// --- adaptation ------------------------------------------------------------ + +/// A state under `preset` with `edit` applied. +fn tuned_state(preset: Preset, edit: impl FnOnce(&mut LoopState)) -> LoopState { + let mut state = LoopState::with_profile("goal", crate::policy::LoopProfile::of(preset)); + edit(&mut state); + state +} + +/// What the rule tuner proposes for `state`, if anything. +fn proposal(state: &LoopState) -> Option { + use crate::arm::Tuner as _; + + let thresholds = state.profile.thresholds; + Rules::default() + .propose( + state, + &serde_json::Value::Null, + crate::step::StepContext::observing(state.passes, &thresholds), + ) + .expect("the rule tuner is total") + .map(|amendment| amendment.change) +} + +#[test] +fn the_rule_tuner_says_nothing_about_an_ordinary_pass() { + // The ordinary answer. A tuner that proposes on every pass has mistaken its + // own budget for a target. + assert_eq!(proposal(&tuned_state(Preset::Balanced, |_| {})), None); + assert_eq!( + proposal(&tuned_state(Preset::Balanced, |state| { + state.unproductive = 1; + state.scores = vec![4, 7]; + })), + None + ); +} + +#[test] +fn the_rule_tuner_raises_patience_only_once_diversifying_has_failed() { + // `unproductive` equal to `stuck` is the pass that diversifies; nothing has + // been learned yet about whether the variation paid. One past it is the + // pass after a diversify that was still unproductive, which is the run's + // own evidence that the threshold was too low for this domain. + let at_the_bound = tuned_state(Preset::Balanced, |state| state.unproductive = 2); + assert_eq!(proposal(&at_the_bound), None); + + let past_it = tuned_state(Preset::Balanced, |state| state.unproductive = 3); + assert_eq!( + proposal(&past_it), + Some(crate::policy::Change::Threshold { + field: crate::policy::ThresholdField::Stuck, + to: 3, + }) + ); +} + +#[test] +fn the_rule_tuner_asks_for_a_threshold_once_and_not_again() { + // Asking twice is how a tuner spends its whole amendment budget arriving + // where one proposal would have put it. A refusal counts as asked: a bound + // that said no once will say no again. + let bounds = Preset::Balanced.bounds(); + let mut state = tuned_state(Preset::Balanced, |state| state.unproductive = 3); + + let first = proposal(&state).expect("the first pass past the bound proposes"); + state.profile.fold( + crate::policy::Amendment::new("tune", 1, first, "because"), + &bounds, + ); + + state.unproductive = 9; + assert_eq!(proposal(&state), None); +} + +#[test] +fn the_rule_tuner_spends_less_when_the_machinery_is_failing() { + // Infrastructure first, and the only move available that costs less rather + // than more. A run whose sandbox will not start has learned nothing about + // its own patience. + let blocked = tuned_state(Preset::Balanced, |state| { + state.blocked = 1; + state.unproductive = 9; + }); + + assert_eq!( + proposal(&blocked), + Some(crate::policy::Change::Cap { + field: crate::policy::CapField::MaxModelCalls, + to: u64::from(crate::budget::Caps::default().max_model_calls / 2), + }), + "the blocked rule outranks the patience rule", + ); +} + +#[test] +fn the_rule_tuner_mutes_a_judge_that_has_stopped_discriminating() { + // Silence, not "scored worse". The loop has no per-arm reward to rank arms + // by, so the rule fires on a signal that has stopped varying and never on a + // comparison it cannot make. + let flat = tuned_state(Preset::Balanced, |state| { + state.scores = vec![9, 4, 4, 4]; + }); + assert_eq!( + proposal(&flat), + Some(crate::policy::Change::MuteArm { + arm: crate::step::STEP_JUDGE.to_owned(), + }) + ); + + let varying = tuned_state(Preset::Balanced, |state| { + state.scores = vec![4, 4, 5]; + }); + assert_eq!(proposal(&varying), None); +} + +#[test] +fn the_rule_tuner_proposes_on_exactly_these_passes() { + // The whole behavior as one sequence, so a change to any rule shows here as + // a changed list rather than as one test flipping. + let sequence: Vec> = [ + (0_u32, 0_u32, vec![]), + (1, 0, vec![4]), + (2, 0, vec![4, 4]), + (3, 0, vec![4, 4, 4]), + (0, 1, vec![]), + ] + .into_iter() + .map(|(unproductive, blocked, scores)| { + let state = tuned_state(Preset::Balanced, |state| { + state.unproductive = unproductive; + state.blocked = blocked; + state.scores = scores; + }); + proposal(&state).map(|change| match change { + crate::policy::Change::Threshold { .. } => "stuck", + crate::policy::Change::Cap { .. } => "cap", + crate::policy::Change::MuteArm { .. } => "mute", + crate::policy::Change::UnmuteArm { .. } => "unmute", + }) + }) + .collect(); + + assert_eq!(sequence, vec![None, None, None, Some("stuck"), Some("cap")],); +} + +#[test] +fn an_amendment_does_not_change_the_route_of_the_pass_that_proposed_it() { + // The fold lands at `pass`, the loop's single exit, so a proposal reaches + // the router exactly one pass later. An arm that could change a threshold + // and have the same pass's route read it would make the route depend on + // whether the tuner finished before the routing node. + let mut state = tuned_state(Preset::Balanced, |state| state.unproductive = 2); + let proposed = crate::policy::Amendment::new( + "tune", + 0, + crate::policy::Change::Threshold { + field: crate::policy::ThresholdField::Stuck, + to: 4, + }, + "because", + ); + state.profile.thresholds.stuck = 2; + + let mut carrying = state.clone(); + crate::state::Contribution { + amendment: Some(proposed), + ..crate::state::Contribution::new("tune") + } + .apply_to(&mut carrying); + + // Proposed, and the route is the one the *current* thresholds give. + assert_eq!(crate::policy::route(&carrying), Route::Diversify); + + // Folded at `pass`, and the next route is the one the new thresholds give. + let arms = ArmSet::new(vec![Arc::new(Reflect), Arc::new(Judge)]).expect("a legal set"); + let advanced = Advance::new(Preset::Balanced.bounds(), &arms) + .expect("judge is declared and does not conclude") + .run( + carrying, + crate::step::StepContext::advancing(0, &Thresholds::default()), + ) + .expect("the pass step folds") + .into_state(); + + assert_eq!(advanced.profile.thresholds.stuck, 4); + assert_eq!(advanced.profile.revision, 1); + assert!(advanced.proposed().is_none(), "a proposal lands once"); + assert_eq!(crate::policy::route(&advanced), Route::Retry); +} + +#[test] +fn a_muted_arm_still_runs_its_node_and_still_converges() { + // Muting removes the arm's work, never its edges. Dropping a convergence + // edge would leave the barrier waiting on an arm nothing will activate. + let mut state = tuned_state(Preset::Balanced, |state| { + state.last_attempt = String::new(); + }); + state + .profile + .muted + .insert(crate::step::STEP_JUDGE.to_owned()); + + let step = ArmStep::new(std::sync::Arc::new(Judge)); + let returned = step + .run( + state.clone(), + crate::step::StepContext::advancing(0, &Thresholds::default()), + ) + .expect("a muted arm still returns") + .into_state(); + + // A zero delta and an empty contribution: it ran, and it contributed + // nothing, which is what the merge needs from it. + assert_eq!(returned, state); + assert_eq!(returned.delta_from(&state), crate::Delta::default()); + assert!( + crate::Contribution::claimed_from(crate::step::STEP_JUDGE, &state, &returned).is_empty() + ); +} + +#[test] +fn muting_the_concluding_arm_is_refused_whatever_the_bounds_say() { + // A run that mutes the one arm able to end it cannot succeed — it would + // run to its iteration cap and report nothing about why. Caught here, at + // assembly, before a tuner ever proposes it. + let arms = ArmSet::new(vec![Arc::new(Reflect), Arc::new(Judge)]).expect("a legal set"); + let bounds = crate::policy::Bounds::none().mutable(crate::step::STEP_REFLECT); + + let error = Advance::new(bounds, &arms).expect_err("reflect concludes the run"); + assert_eq!( + error, + Error::IneligibleMutableArm { + arm: crate::step::STEP_REFLECT.to_owned(), + reason: "it is the run's concluding arm", + } + ); +} + +#[test] +fn muting_an_undeclared_arm_is_refused_rather_than_a_recorded_no_op() { + // An arm not in the set has no room to mute at all: accepting the + // amendment would spend the run's amendment budget recording a change + // that touches nothing. + let arms = ArmSet::new(vec![Arc::new(Reflect), Arc::new(Judge)]).expect("a legal set"); + let bounds = crate::policy::Bounds::none().mutable("no-such-arm"); + + let error = Advance::new(bounds, &arms).expect_err("no arm named no-such-arm is declared"); + assert_eq!( + error, + Error::IneligibleMutableArm { + arm: "no-such-arm".to_owned(), + reason: "the run's arm set does not declare it", + } + ); +} + +#[test] +fn a_tuned_run_carries_a_third_arm_and_a_different_graph() { + let plain = assembled(Preset::Balanced).expect("the preset assembles"); + let tuned = tuned_research_loop( + "bound the error term", + Preset::Balanced, + delegates(), + plan(), + Arc::new(Inline::of( + delegates(), + [( + "prover".to_owned(), + vec![Scripted::Answers { + reply: "still working".to_owned(), + artifacts: Vec::new(), + }], + )], + )), + ) + .expect("the tuned preset assembles"); + + // A run that can revise itself and one that cannot are different loops, and + // the difference is in the topology rather than in an argument. + let graph = tuned.graph().expect("the tuned graph validates"); + assert!(graph.node(Rules::NAME).is_some()); + assert!( + plain + .graph() + .expect("the plain graph validates") + .node(Rules::NAME) + .is_none() + ); + assert_ne!( + plain.signature().expect("signed"), + tuned.signature().expect("signed"), + ); +} + +#[test] +fn a_tuned_run_reports_every_revision_and_every_refusal() { + // End to end, over the reference seams: the specialist keeps failing, the + // run notices the machinery rather than the work, and its report says what + // it changed about itself. + let tuned = tuned_research_loop( + "bound the error term", + Preset::Balanced, + delegates(), + plan(), + Arc::new(Inline::of( + delegates(), + [( + "prover".to_owned(), + // Every pass fails on the machinery rather than on the work, + // which is the run the blocked rule exists for. + std::iter::repeat_with(|| Scripted::Fails { + reason: "the sandbox would not start".to_owned(), + }) + .take(12) + .collect(), + )], + )), + ) + .expect("the tuned preset assembles"); + + let sink = Arc::new(LineSink::new(std::io::sink())); + let recorder = Recorder::new("run", sink); + let driven = tuned.drive(&recorder).expect("a tuned run drives"); + + // The profile is an output, not a detail of the state. + assert_eq!(driven.profile, driven.state.profile); + + // Whatever it proposed, every proposal is in the record and in the report, + // and the events say which were folded and which were refused. + let amended = recorder + .journal() + .into_iter() + .filter(|entry| { + matches!( + entry.event, + Event::Amended { .. } | Event::AmendmentRefused { .. } + ) + }) + .count(); + assert_eq!(amended, driven.profile.history.len()); + + if !driven.profile.history.is_empty() { + assert!(driven.answer().contains("Revised itself")); + for recorded in &driven.profile.history { + assert!( + driven + .answer() + .contains(&recorded.amendment.change.to_string()), + "the report omits {recorded}", + ); + } + } +} + +#[test] +fn a_plain_run_proposes_nothing_and_says_nothing_about_revisions() { + // The tuner is optional, and a loop without one costs nothing for it and + // reports no section about it. + let driven = assembled(Preset::Balanced) + .expect("the preset assembles") + .drive(&Recorder::new( + "run", + Arc::new(LineSink::new(std::io::sink())), + )) + .expect("a plain run drives"); + + assert!(driven.profile.history.is_empty()); + assert_eq!(driven.profile.revision, 0); + assert!(!driven.answer().contains("Revised itself")); +} + +#[test] +fn a_run_the_machinery_keeps_failing_spends_less_rather_than_more() { + // The end-to-end version of the blocked rule, asserted rather than left to + // the "if it proposed anything" branch above: a run whose specialist never + // starts should reach for a smaller model-call allowance, not more + // attempts. + let tuned = tuned_research_loop( + "bound the error term", + Preset::Balanced, + delegates(), + plan(), + Arc::new(Inline::of( + delegates(), + [( + "prover".to_owned(), + std::iter::repeat_with(|| Scripted::Fails { + reason: "the sandbox would not start".to_owned(), + }) + .take(12) + .collect(), + )], + )), + ) + .expect("the tuned preset assembles"); + + let driven = tuned + .drive(&Recorder::new( + "run", + Arc::new(LineSink::new(std::io::sink())), + )) + .expect("a tuned run drives"); + + assert!( + driven.profile.history.iter().any(|recorded| matches!( + recorded.amendment.change, + crate::policy::Change::Cap { + field: crate::policy::CapField::MaxModelCalls, + .. + } + )), + "a blocked run proposed nothing about its spend: {:?}", + driven.profile.history, + ); + assert!(driven.profile.applied() > 0); + assert!(driven.profile.caps.max_model_calls < crate::budget::Caps::default().max_model_calls); +} + +#[test] +fn a_raised_attempt_ceiling_buys_passes() { + // The claim the backstop exists to keep honest. Raising `max_attempts` is + // only a real amendment if the head will actually run the extra passes; + // left capped at the starting ceiling it would fold, read back as raised, + // and buy nothing. + let bounds = Preset::Balanced.bounds(); + let mut profile = crate::policy::LoopProfile::of(Preset::Balanced); + let ceiling = crate::budget::Caps::default().max_iterations; + + profile.fold( + crate::policy::Amendment::new( + "tune", + 1, + crate::policy::Change::Threshold { + field: crate::policy::ThresholdField::MaxAttempts, + to: ceiling, + }, + "the run is close and out of attempts", + ), + &bounds, + ); + + assert_eq!(profile.thresholds.max_attempts, ceiling); + + // A state one short of the raised ceiling still routes onward, where the + // starting ceiling would have stopped it. + let mut state = LoopState::with_profile("goal", profile); + state.attempts = ceiling - 1; + assert_eq!(crate::policy::route(&state), Route::Retry); + + let mut unamended = + LoopState::with_profile("goal", crate::policy::LoopProfile::of(Preset::Balanced)); + unamended.attempts = ceiling - 1; + assert_eq!(crate::policy::route(&unamended), Route::Solved); +} diff --git a/crates/tinyloops/src/presets/tuner.rs b/crates/tinyloops/src/presets/tuner.rs new file mode 100644 index 0000000..7e46704 --- /dev/null +++ b/crates/tinyloops/src/presets/tuner.rs @@ -0,0 +1,226 @@ +//! The shipped tuner: a pure function of the counters, and why it is not a +//! model. +//! +//! [`Rules`] reads the accumulator and proposes at most one [`Amendment`] a +//! pass. Every rule is arithmetic over fields the run already carries, so the +//! whole behavior is testable at every boundary by driving a counter sequence +//! and asserting the exact passes it fires on. +//! +//! # Why the default is rules and not a model +//! +//! A model asked mid-run whether its own configuration is wrong has no ground +//! truth to answer from and every incentive to answer yes — the same pressure +//! that makes a model claim the goal is met on the eighth pass. Worse, the +//! tuner and the loop it tunes are the same system, so a model tuner rewarded +//! by the loop's own signals can improve the signal instead of the work. +//! +//! A model tuner is still permitted: it implements the same [`Tuner`] trait and +//! is bounded by the same [`Bounds`](crate::Bounds), which is precisely why the +//! bounds live outside the proposer. +//! +//! # Why the muting rule is conservative +//! +//! The obvious way to retire an arm is to score arms against each other and +//! drop the worst, which is what bandit arm-elimination does. It needs a +//! measured reward per arm, and this loop has none: an arm contributes a delta +//! and a narrative, not a score of its own. So [`Rules`] fires on *silence* — +//! an arm whose signal has not varied for a window of passes — and never on +//! "scored worse", because it has no such comparison to make. + +use serde_json::Value; + +use crate::arm::Tuner; +use crate::policy::{Amendment, CapField, Change, LoopProfile, ThresholdField}; +use crate::state::LoopState; +use crate::step::{NoWrite, StepContext}; +use crate::{Result, step::STEP_JUDGE}; + +/// How many identical scores in a row read as a judge carrying no signal, +/// when nothing says otherwise. +/// +/// Three, matching [`DEFAULT_MUTING_WINDOW`](crate::DEFAULT_MUTING_WINDOW). +/// Two is a coincidence; three is a pattern cheap enough to act on and cheap +/// enough to be wrong about, since the only cost of a wrong mute is one arm's +/// work on a run that was going to spend it anyway. +pub const SILENT_SCORES: usize = 3; + +/// The rule-based tuner. +/// +/// Carries the muting window a deployment's [`Bounds`](crate::Bounds) +/// configured, and nothing else it needs from the accumulator: everything is +/// in the state handed to [`Tuner::propose`], which is what makes it safe to +/// run under a replay — proposing twice from the same state proposes the same +/// thing, and the `pass` step folds one proposal once. +#[derive(Debug, Clone, Copy)] +pub struct Rules { + window: usize, +} + +impl Default for Rules { + /// A tuner that mutes on [`SILENT_SCORES`] consecutive identical scores — + /// the shipped presets' declared window, matched here so a caller who + /// wires up [`Rules::default`] directly sees the same behavior the + /// presets do. + fn default() -> Self { + Self { + window: SILENT_SCORES, + } + } +} + +impl Rules { + /// The arm's name, and the id of its node. + pub const NAME: &'static str = "tune"; + + /// A tuner that mutes an arm after `window` consecutive identical judge + /// scores, rather than the default [`SILENT_SCORES`]. + /// + /// `window` should come from the same [`Bounds::muting_window`] the + /// assembled loop folds amendments within — otherwise the tuner proposes + /// on a cadence the bounds were never asked about. + /// + /// [`Bounds::muting_window`]: crate::Bounds::muting_window + #[must_use] + pub fn new(window: u32) -> Self { + Self { + window: usize::try_from(window).unwrap_or(usize::MAX), + } + } + + /// Whether `profile` has already been asked to move `field`. + /// + /// Proposing the same move twice is how a tuner spends its whole amendment + /// budget arriving where one proposal would have put it. Refusals count + /// too: a bound that said no once will say no again, and re-asking buys a + /// second identical refusal in the run's record. + fn already_asked(profile: &LoopProfile, field: ThresholdField) -> bool { + profile.history.iter().any(|recorded| { + matches!( + recorded.amendment.change, + Change::Threshold { field: asked, .. } if asked == field + ) + }) + } + + /// Whether `profile` has already been asked to move a cap. + fn already_capped(profile: &LoopProfile, field: CapField) -> bool { + profile.history.iter().any(|recorded| { + matches!( + recorded.amendment.change, + Change::Cap { field: asked, .. } if asked == field + ) + }) + } + + /// The last `self.window` scores, when they exist and are all equal. + /// + /// A zero window never reads as flat: there is no run of zero equal + /// scores that means anything, and treating it as "always silent" would + /// mute the arm on its first pass. + fn scores_are_flat(self, state: &LoopState) -> bool { + let scores = &state.scores; + self.window > 0 + && scores.len() >= self.window + && scores[scores.len() - self.window..] + .windows(2) + .all(|pair| pair[0] == pair[1]) + } + + /// The infrastructure rule. + /// + /// A run one pass from its blocked ceiling is a run the machinery is + /// failing, and more attempts cannot answer that. Halving the model-call + /// allowance is the only move available that costs less rather than more. + fn on_blocked(state: &LoopState) -> Option { + let thresholds = &state.profile.thresholds; + let near = thresholds.blocked.saturating_sub(1).max(1); + if state.blocked < near || Self::already_capped(&state.profile, CapField::MaxModelCalls) { + return None; + } + let halved = (state.profile.caps.max_model_calls / 2).max(1); + Some(Change::Cap { + field: CapField::MaxModelCalls, + to: u64::from(halved), + }) + } + + /// The patience rule. + /// + /// `unproductive` strictly past `stuck` means the run has already + /// diversified once and the pass after it was unproductive too — so the + /// variation the threshold bought did not pay, and the run's own estimate + /// of where sequential revision stops beating sampling was too low for this + /// domain. + fn on_diversifying_badly(state: &LoopState) -> Option { + let stuck = state.profile.thresholds.stuck; + if state.unproductive <= stuck || Self::already_asked(&state.profile, ThresholdField::Stuck) + { + return None; + } + Some(Change::Threshold { + field: ThresholdField::Stuck, + to: stuck.saturating_add(1), + }) + } + + /// The silence rule. + /// + /// A judge returning the same score for a window of passes is a judge whose + /// score is carrying no information, and the run is paying for it every + /// pass. Muting it is not a claim that it was wrong. + fn on_silence(self, state: &LoopState) -> Option { + if !self.scores_are_flat(state) || state.profile.is_muted(STEP_JUDGE) { + return None; + } + Some(Change::MuteArm { + arm: STEP_JUDGE.to_owned(), + }) + } + + /// The reason line that travels with `change`. + fn because(self, state: &LoopState, change: &Change) -> String { + match change { + Change::Cap { .. } => format!( + "{} consecutive blocked passes; the machinery is failing, not the work", + state.blocked + ), + Change::Threshold { .. } => format!( + "unproductive is {} against a stuck of {}: diversifying did not pay", + state.unproductive, state.profile.thresholds.stuck + ), + Change::MuteArm { arm } => format!( + "{arm} has scored {:?} for {} passes and is carrying no signal", + state.scores.last().copied().unwrap_or_default(), + self.window + ), + Change::UnmuteArm { arm } => format!("{arm} is wanted again"), + } + } +} + +impl Tuner for Rules { + fn name(&self) -> &'static str { + Self::NAME + } + + fn propose( + &self, + base: &LoopState, + _report: &Value, + ctx: StepContext<'_, NoWrite>, + ) -> Result> { + // Ordered, and the order is the policy: infrastructure first, because a + // run the machinery is failing has learned nothing about its own + // patience; then patience; then what the run is paying for and not + // reading. At most one proposal a pass, so a busy pass does not spend + // the whole amendment budget at once. + let change = Self::on_blocked(base) + .or_else(|| Self::on_diversifying_badly(base)) + .or_else(|| self.on_silence(base)); + + Ok(change.map(|change| { + let because = self.because(base, &change); + Amendment::new(Self::NAME, ctx.pass(), change, because) + })) + } +} diff --git a/crates/tinyloops/src/presets/types.rs b/crates/tinyloops/src/presets/types.rs index 182f1b8..c78f7f4 100644 --- a/crates/tinyloops/src/presets/types.rs +++ b/crates/tinyloops/src/presets/types.rs @@ -11,7 +11,13 @@ //! without being swept is a preset whose generated ladder nobody proved agrees //! with [`route`](crate::route). -use crate::policy::Thresholds; +use serde::{Deserialize, Serialize}; + +use crate::budget::Caps; +use crate::policy::{ + Bounds, CapField, DEFAULT_MAX_AMENDMENTS, DEFAULT_MUTING_WINDOW, Range, ThresholdField, + Thresholds, +}; /// A shipped threshold set. /// @@ -21,7 +27,18 @@ use crate::policy::Thresholds; /// self-revision beats drawing several independent attempts only while feedback /// accuracy is high; below that, sampling and selecting wins. Every preset here /// is an estimate of which side of that crossing a domain sits on. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +/// +/// # Wire form +/// +/// The serde names are [`Preset::as_str`], and `src/presets/test.rs` asserts +/// the two agree. A preset travels inside the run's accumulator as part of +/// [`LoopProfile`](crate::LoopProfile), so its names are a wire format: a +/// variant renamed on one side and not the other is a decode error at run time +/// rather than a compile error. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize, +)] +#[serde(rename_all = "lowercase")] #[non_exhaustive] pub enum Preset { /// The shipped default: [`Thresholds::default`]. @@ -30,6 +47,7 @@ pub enum Preset { /// third time. It is the set every other one here is a deliberate deviation /// from, and the set a domain should start on before it has measured /// anything. + #[default] Balanced, /// Revise for longer before drawing a fresh approach. /// @@ -121,6 +139,75 @@ impl Preset { } } +impl Preset { + /// The room a run on this preset has to revise itself. + /// + /// The room is part of the bet, not separate from it. A preset that commits + /// to persistence should not be able to tune its way to variation in three + /// moves, and a preset that commits to caution should have less room than + /// one that does not — so `Cautious` folds fewer amendments than the rest. + /// + /// Every ceiling on [`Thresholds::max_attempts`] stops at the loop head's + /// runaway backstop, [`Caps::max_iterations`](crate::Caps::max_iterations). + /// Above it an amendment would fold, read back as raised, and buy nothing; + /// `src/presets/test.rs` asserts the relationship rather than the number. + /// + /// The one arm named mutable is the judge, which is the shipped loop's + /// non-concluding arm. An embedder with different arms narrows this or + /// replaces it; muting the arm that may *conclude* is refused whatever the + /// bounds say, because a run that cannot conclude cannot succeed. + /// + /// # Examples + /// + /// ``` + /// # use tinyloops::{Bounds, Change, Preset, ThresholdField}; + /// let bounds = Preset::Balanced.bounds(); + /// let harder = Change::Threshold { field: ThresholdField::Stuck, to: 4 }; + /// let absurd = Change::Threshold { field: ThresholdField::Stuck, to: 40 }; + /// + /// assert!(bounds.check(&harder).is_ok()); + /// assert!(bounds.check(&absurd).is_err()); + /// ``` + #[must_use] + pub fn bounds(self) -> Bounds { + let ceiling = u64::from(Caps::default().max_iterations); + let common = Bounds::none() + .window(DEFAULT_MUTING_WINDOW) + .amendments(DEFAULT_MAX_AMENDMENTS) + .mutable(crate::step::STEP_JUDGE) + .threshold(ThresholdField::PlanInterval, Range::new(2, 6)) + .cap( + CapField::MaxModelCalls, + Range::new(1, u64::from(Caps::default().max_model_calls)), + ); + + match self { + // The middle of the road may move either way, as far as the two + // presets on either side of it already sit. + Self::Balanced => common + .threshold(ThresholdField::MaxAttempts, Range::new(4, ceiling)) + .threshold(ThresholdField::Stuck, Range::new(1, 4)) + .threshold(ThresholdField::Computational, Range::new(1, 4)), + // A persistent run may become more patient still, and may come back + // toward the middle — but not all the way to sampling, which would + // be a different preset rather than a revision of this one. + Self::Persistent => common + .threshold(ThresholdField::MaxAttempts, Range::new(6, ceiling)) + .threshold(ThresholdField::Stuck, Range::new(2, 6)), + // The variation bet moves within variation. + Self::Exploratory => common + .threshold(ThresholdField::MaxAttempts, Range::new(4, ceiling)) + .threshold(ThresholdField::Stuck, Range::new(1, 3)) + .threshold(ThresholdField::Computational, Range::new(1, 3)), + // Caution is the one preset that bounds its own revising: half the + // amendments, and only the threshold that expresses the caution. + Self::Cautious => common + .amendments(DEFAULT_MAX_AMENDMENTS / 2) + .threshold(ThresholdField::Unverified, Range::new(1, 3)), + } + } +} + impl std::fmt::Display for Preset { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) diff --git a/crates/tinyloops/src/state/mod.rs b/crates/tinyloops/src/state/mod.rs index 76f8f3d..c534a1f 100644 --- a/crates/tinyloops/src/state/mod.rs +++ b/crates/tinyloops/src/state/mod.rs @@ -41,7 +41,7 @@ //! not panic, because this code runs inside a node the engine is not able to //! unwind sensibly. -use crate::policy::Judgement; +use crate::policy::{Judgement, LoopProfile}; mod types; @@ -99,6 +99,41 @@ impl LoopState { } } + /// Starts a run on `goal` under `profile`. + /// + /// [`Self::new`] seeds the default profile, which is the balanced preset. + /// This is the constructor a run with any other preset uses, and it is the + /// only place the profile is ever chosen: nothing after construction moves + /// it. + /// + /// # Examples + /// + /// ``` + /// # use tinyloops::{LoopProfile, LoopState, Preset}; + /// let state = LoopState::with_profile("ship it", LoopProfile::of(Preset::Persistent)); + /// assert_eq!(state.profile.thresholds.stuck, 4); + /// ``` + #[must_use] + pub fn with_profile(goal: impl Into, profile: LoopProfile) -> Self { + Self { + profile, + ..Self::new(goal) + } + } + + /// The amendment this pass proposed, if one did. + /// + /// # Examples + /// + /// ``` + /// # use tinyloops::LoopState; + /// assert!(LoopState::new("goal").proposed().is_none()); + /// ``` + #[must_use] + pub fn proposed(&self) -> Option<&crate::policy::Amendment> { + self.proposed.as_ref() + } + /// Returns the movement from `base` to `self`. /// /// Only the counters and the two latching flags move; see [`Delta`] for why @@ -188,6 +223,8 @@ impl LoopState { scores: self.scores.clone(), judged: self.judged, board: self.board.clone(), + profile: self.profile.clone(), + proposed: self.proposed.clone(), answer: self.answer.clone(), } } @@ -234,6 +271,7 @@ impl LoopState { let mut score: Option<(&'static str, u8)> = None; let mut judged: Option<(&'static str, Judgement)> = None; let mut last_attempt: Option<(&'static str, String)> = None; + let mut amendment: Option<(&'static str, crate::policy::Amendment)> = None; for contribution in contributions { claim( @@ -256,6 +294,12 @@ impl LoopState { contribution.last_attempt.clone(), "last_attempt", )?; + claim( + &mut amendment, + contribution.arm, + contribution.amendment.clone(), + "amendment", + )?; } if let Some((_, lesson)) = lesson { @@ -273,6 +317,9 @@ impl LoopState { if let Some((_, report)) = last_attempt { merged.last_attempt = report; } + if let Some((_, proposed)) = amendment { + merged.proposed = Some(proposed); + } Ok(merged) } diff --git a/crates/tinyloops/src/state/test.rs b/crates/tinyloops/src/state/test.rs index 31fb21f..4117cd7 100644 --- a/crates/tinyloops/src/state/test.rs +++ b/crates/tinyloops/src/state/test.rs @@ -42,6 +42,8 @@ fn populated() -> LoopState { scores: vec![7, 9], judged: Judgement::Steer, board: board(), + profile: crate::policy::LoopProfile::of(crate::presets::Preset::Cautious), + proposed: None, answer: "the bound holds".to_string(), } } @@ -94,6 +96,23 @@ fn the_wire_form_is_pinned() { }], "planned_at": 2, }, + "profile": { + "revision": 0, + "thresholds": { + "max_attempts": 8, + "stuck": 2, + "blocked": 2, + "computational": 2, + "unverified": 1, + "max_restarts": 2, + "plan_interval": 3, + }, + "origin": "cautious", + "caps": serde_json::to_value(crate::budget::Caps::default()).unwrap(), + "muted": [], + "history": [], + }, + "proposed": null, "answer": "the bound holds", }) ); diff --git a/crates/tinyloops/src/state/types.rs b/crates/tinyloops/src/state/types.rs index e4374f4..8346118 100644 --- a/crates/tinyloops/src/state/types.rs +++ b/crates/tinyloops/src/state/types.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; -use crate::policy::Judgement; +use crate::policy::{Judgement, LoopProfile}; /// What one goal run carries from turn to turn. /// @@ -103,6 +103,33 @@ pub struct LoopState { /// because a decomposition that lives anywhere else is a decomposition the /// loop head is not the sole writer of. pub board: crate::orchestrate::TaskBoard, + /// The configuration this run is operating under. + /// + /// The routing ladder reads its thresholds from here, at + /// `.profile.thresholds.`, rather than from numbers rendered into + /// the graph — see [`LoopProfile`] and + /// `docs/adr/0006-thresholds-addressed-from-run-state.md`. Like + /// [`Self::board`] it is nested, and for the same reason: it has to survive + /// a checkpoint with the guarantee every counter has, and a configuration + /// that lived anywhere else is a configuration the loop head is not the + /// sole writer of. + /// + /// [`Self::apply`] carries it through the fold untouched and neither + /// [`Delta`] nor [`Contribution`] has a slot that reaches it, so no arm can + /// move it however it is wired. + pub profile: LoopProfile, + /// The amendment this pass proposed, if one did. + /// + /// `pub(crate)` on purpose, and it is the whole of the "one proposer" rule. + /// An arm outside this crate is handed a [`LoopState`] it may edit freely + /// and cannot reach this field, so `impl Arm` + "propose an amendment" does + /// not compile. The only writer is + /// [`TunerArm`](crate::TunerArm), the adapter over the + /// [`Tuner`](crate::Tuner) trait. Read it with [`Self::proposed`]. + /// + /// Cleared by the `pass` step once the head has folded it, so a proposal + /// lands exactly once. + pub(crate) proposed: Option, /// The run's final answer, written by the `report` step alone. /// /// Sole authorship is structural rather than conventional: [`Self::apply`] @@ -183,6 +210,12 @@ pub struct Contribution { pub judged: Option, /// The attempt report, replacing [`LoopState::last_attempt`]. pub last_attempt: Option, + /// The amendment the tuner proposed, if it proposed one. + /// + /// `pub(crate)` for the same reason [`LoopState::proposed`] is: an arm + /// outside this crate cannot fill it, so exactly one role can propose, and + /// that is a fact about what compiles rather than a rule in a document. + pub(crate) amendment: Option, } impl Contribution { @@ -196,9 +229,16 @@ impl Contribution { score: None, judged: None, last_attempt: None, + amendment: None, } } + /// The amendment this contribution carries, if it carries one. + #[must_use] + pub fn amendment(&self) -> Option<&crate::policy::Amendment> { + self.amendment.as_ref() + } + /// Applies these claims to `state`. /// /// The inverse of [`Self::claimed_from`], and the reason both exist: under @@ -227,6 +267,9 @@ impl Contribution { if let Some(last_attempt) = self.last_attempt.clone() { state.last_attempt = last_attempt; } + if let Some(amendment) = self.amendment.clone() { + state.proposed = Some(amendment); + } } /// Reads back what `candidate` claims, relative to `base`. @@ -259,6 +302,9 @@ impl Contribution { judged: (candidate.judged != base.judged).then_some(candidate.judged), last_attempt: (candidate.last_attempt != base.last_attempt) .then(|| candidate.last_attempt.clone()), + amendment: (candidate.proposed != base.proposed) + .then(|| candidate.proposed.clone()) + .flatten(), } } @@ -273,5 +319,6 @@ impl Contribution { && self.score.is_none() && self.judged.is_none() && self.last_attempt.is_none() + && self.amendment.is_none() } } diff --git a/crates/tinyloops/src/step/mod.rs b/crates/tinyloops/src/step/mod.rs index f879ff0..01982d9 100644 --- a/crates/tinyloops/src/step/mod.rs +++ b/crates/tinyloops/src/step/mod.rs @@ -58,7 +58,6 @@ use std::sync::Arc; use serde_json::Value; -use crate::policy::Thresholds; use crate::state::LoopState; use crate::{Error, Result}; @@ -216,9 +215,9 @@ impl StepRegistry { /// /// Returns [`Error::UnknownStep`] when `name` is not registered, or /// whatever error the body raises. - pub fn run(&self, name: &str, state: LoopState, thresholds: &Thresholds) -> Result { + pub fn run(&self, name: &str, state: LoopState) -> Result { let pass = state.passes; - self.get(name)?.run(state, pass, thresholds) + self.get(name)?.run(state, pass) } /// Runs the body registered under `name`, handing it `args`. @@ -231,15 +230,9 @@ impl StepRegistry { /// /// Returns [`Error::UnknownStep`] when `name` is not registered, or /// whatever error the body raises. - pub fn run_with( - &self, - name: &str, - state: LoopState, - thresholds: &Thresholds, - args: &Value, - ) -> Result { + pub fn run_with(&self, name: &str, state: LoopState, args: &Value) -> Result { let pass = state.passes; - self.get(name)?.run_with(state, pass, thresholds, args) + self.get(name)?.run_with(state, pass, args) } } @@ -268,8 +261,7 @@ impl std::fmt::Debug for StepRegistry { /// # use std::sync::Arc; /// # use serde_json::json; /// # use tinyloops::{ -/// # Advanced, CanWrite, LoopState, Result, Step, StepContext, StepRegistry, Thresholds, -/// # run_loop_step, +/// # Advanced, CanWrite, LoopState, Result, Step, StepContext, StepRegistry, run_loop_step, /// # }; /// struct Solve; /// @@ -288,7 +280,7 @@ impl std::fmt::Debug for StepRegistry { /// registry.register(Arc::new(Solve))?; /// /// let args = json!({ "step": "solve", "state": LoopState::new("goal") }); -/// let returned = run_loop_step(®istry, &Thresholds::default(), &args)?; +/// let returned = run_loop_step(®istry, &args)?; /// /// assert_eq!(returned["solved"], json!(true)); /// assert_eq!(returned["goal"], json!("goal")); // the *whole* state came back @@ -306,18 +298,14 @@ impl std::fmt::Debug for StepRegistry { /// - [`Error::StateEncoding`] when the returned accumulator cannot be /// serialized. /// - Whatever error the body itself raises. -pub fn run_loop_step( - registry: &StepRegistry, - thresholds: &Thresholds, - args: &Value, -) -> Result { +pub fn run_loop_step(registry: &StepRegistry, args: &Value) -> Result { let name = args .get("step") .and_then(Value::as_str) .ok_or(Error::MalformedStepPayload { field: "step" })?; let state = types::decode_state(args)?; - let returned = registry.run_with(name, state, thresholds, args)?; + let returned = registry.run_with(name, state, args)?; serde_json::to_value(returned).map_err(|_| Error::StateEncoding) } diff --git a/crates/tinyloops/src/step/test.rs b/crates/tinyloops/src/step/test.rs index 8822014..c6b4600 100644 --- a/crates/tinyloops/src/step/test.rs +++ b/crates/tinyloops/src/step/test.rs @@ -18,7 +18,7 @@ use serde_json::json; use super::*; -use crate::policy::Judgement; +use crate::policy::{Judgement, Thresholds}; /// A step that stamps the pass number it was handed onto `attempts`. /// @@ -168,9 +168,7 @@ fn runs_the_named_step_and_returns_the_whole_state() { state.passes = 2; state.lessons.push("something learned".to_string()); - let returned = registry - .run(STEP_ATTEMPT, state, &Thresholds::default()) - .unwrap(); + let returned = registry.run(STEP_ATTEMPT, state).unwrap(); assert_eq!(returned.attempts, 3); assert_eq!(returned.last_attempt, "pass 2"); @@ -192,9 +190,7 @@ fn an_observing_step_returns_the_state_it_was_handed() { state.passes = 4; state.judged = Judgement::Steer; - let returned = registry - .run("counting", state.clone(), &Thresholds::default()) - .unwrap(); + let returned = registry.run("counting", state.clone()).unwrap(); assert_eq!(returned, state); assert_eq!(*observer.seen.lock().unwrap(), [4]); @@ -206,9 +202,7 @@ fn a_failing_step_reports_rather_than_returning_the_state_unchanged() { registry.register(Arc::new(Broken)).unwrap(); assert_eq!( - registry - .run("broken", LoopState::new("goal"), &Thresholds::default()) - .unwrap_err(), + registry.run("broken", LoopState::new("goal")).unwrap_err(), Error::EmptyName, ); } @@ -222,11 +216,7 @@ fn a_failing_observer_reports_rather_than_being_swallowed() { assert_eq!( registry - .run( - "broken_observer", - LoopState::new("goal"), - &Thresholds::default() - ) + .run("broken_observer", LoopState::new("goal")) .unwrap_err(), Error::EmptyName, ); @@ -251,12 +241,8 @@ fn the_tool_runs_the_named_step_and_returns_the_state_as_json() { let mut state = LoopState::new("goal"); state.passes = 1; - let returned = run_loop_step( - ®istry, - &Thresholds::default(), - &json!({ "step": STEP_ATTEMPT, "state": state }), - ) - .unwrap(); + let returned = + run_loop_step(®istry, &json!({ "step": STEP_ATTEMPT, "state": state })).unwrap(); assert_eq!(returned["attempts"], json!(2)); assert_eq!(returned["goal"], json!("goal")); @@ -264,7 +250,7 @@ fn the_tool_runs_the_named_step_and_returns_the_state_as_json() { // slot with exactly this value. The count is spelled out rather than // derived so that adding a field to `LoopState` without deciding how the // head folds it fails here. - assert_eq!(returned.as_object().unwrap().len(), 19); + assert_eq!(returned.as_object().unwrap().len(), 21); } #[test] @@ -274,7 +260,6 @@ fn the_tool_rejects_an_unknown_step_name() { assert_eq!( run_loop_step( ®istry, - &Thresholds::default(), &json!({ "step": "nope", "state": LoopState::new("goal") }), ) .unwrap_err(), @@ -289,12 +274,7 @@ fn the_tool_rejects_a_payload_with_no_step_name() { let registry = registry(); assert_eq!( - run_loop_step( - ®istry, - &Thresholds::default(), - &json!({ "state": LoopState::new("goal") }), - ) - .unwrap_err(), + run_loop_step(®istry, &json!({ "state": LoopState::new("goal") }),).unwrap_err(), Error::MalformedStepPayload { field: "step" }, ); } @@ -306,7 +286,6 @@ fn the_tool_rejects_a_step_name_that_is_not_a_string() { assert_eq!( run_loop_step( ®istry, - &Thresholds::default(), &json!({ "step": 7, "state": LoopState::new("goal") }), ) .unwrap_err(), @@ -319,12 +298,7 @@ fn the_tool_rejects_a_payload_with_no_state() { let registry = registry(); assert_eq!( - run_loop_step( - ®istry, - &Thresholds::default(), - &json!({ "step": "attempt" }) - ) - .unwrap_err(), + run_loop_step(®istry, &json!({ "step": "attempt" })).unwrap_err(), Error::MalformedStepPayload { field: "state" }, ); } @@ -336,7 +310,6 @@ fn the_tool_rejects_a_state_that_is_not_an_accumulator() { assert_eq!( run_loop_step( ®istry, - &Thresholds::default(), &json!({ "step": "attempt", "state": { "passes": "many" } }), ) .unwrap_err(), @@ -379,3 +352,16 @@ fn debug_rendering_names_the_registered_steps() { assert!(rendered.contains("attempt")); assert!(format!("{:?}", registry.get(STEP_ATTEMPT).unwrap()).contains("advances: true")); } + +#[test] +fn a_step_is_handed_the_thresholds_its_state_carries() { + // The seam reads the thresholds off the state rather than from a caller: + // a body handed a threshold set the run is not using would route on + // numbers nobody configured, and nothing would report it. + let registry = registry(); + let state = LoopState::with_profile("goal", crate::LoopProfile::of(crate::Preset::Persistent)); + + let returned = registry.run(STEP_ATTEMPT, state).unwrap(); + + assert_eq!(returned.profile.thresholds.stuck, 4); +} diff --git a/crates/tinyloops/src/step/types.rs b/crates/tinyloops/src/step/types.rs index f325033..eb9ac96 100644 --- a/crates/tinyloops/src/step/types.rs +++ b/crates/tinyloops/src/step/types.rs @@ -350,8 +350,8 @@ impl RegisteredStep { /// # Errors /// /// Returns whatever [`Error`] the body raises. - pub fn run(&self, state: LoopState, pass: u32, thresholds: &Thresholds) -> Result { - self.run_with(state, pass, thresholds, &NO_ARGS) + pub fn run(&self, state: LoopState, pass: u32) -> Result { + self.run_with(state, pass, &NO_ARGS) } /// Runs the body, handing it the arguments its node was invoked with. @@ -363,15 +363,19 @@ impl RegisteredStep { &self, state: LoopState, pass: u32, - thresholds: &Thresholds, args: &serde_json::Value, ) -> Result { + // Copied out before `state` moves, and taken from the state itself + // rather than from a caller: a body handed thresholds the run is not + // using would route on numbers nobody configured, and nothing would + // report it. + let thresholds = state.profile.thresholds; match self { Self::Advancing(step) => step - .run(state, StepContext::advancing_with(pass, thresholds, args)) + .run(state, StepContext::advancing_with(pass, &thresholds, args)) .map(Advanced::into_state), Self::Observing(observer) => { - observer.observe(&state, StepContext::observing_with(pass, thresholds, args))?; + observer.observe(&state, StepContext::observing_with(pass, &thresholds, args))?; Ok(state) } } diff --git a/crates/tinyloops/tests/e2e.rs b/crates/tinyloops/tests/e2e.rs index 649441a..d8739ff 100644 --- a/crates/tinyloops/tests/e2e.rs +++ b/crates/tinyloops/tests/e2e.rs @@ -43,7 +43,7 @@ use tinyflows::testkit::{MockCaps, RunTrace, RunTracer}; use tinyloops::{ Artifact, DelegateSet, Error, FixedPlan, Inline, LineSink, LoopState, Preset, RUN_LOOP_STEP, - Recorder, SOLVED_MARKER, Scripted, StepRegistry, Thresholds, research_loop, run_loop_step, + Recorder, SOLVED_MARKER, Scripted, StepRegistry, research_loop, run_loop_step, }; // ------------------------------------------------------------------ fixtures @@ -65,10 +65,7 @@ fn plan() -> Arc { /// A preset assembled over a script, returning both the graph and the registry /// the engine will reach through the tool. -fn assembled( - preset: Preset, - script: Vec<(&str, Vec)>, -) -> (WorkflowGraph, Thresholds, StepRegistry) { +fn assembled(preset: Preset, script: Vec<(&str, Vec)>) -> (WorkflowGraph, StepRegistry) { let loop_ = research_loop( "bound the error term in the partial sum", preset, @@ -85,8 +82,7 @@ fn assembled( .expect("the preset assembles"); let graph = loop_.graph().expect("the emitted graph validates"); - let thresholds = *loop_.thresholds(); - (graph, thresholds, loop_.registry().clone()) + (graph, loop_.registry().clone()) } fn answers(reply: &str, artifacts: Vec) -> Scripted { @@ -139,15 +135,13 @@ fn solving_script() -> Vec<(&'static str, Vec)> { /// proves the node computes the right thing from what the graph handed it. struct Steps { registry: StepRegistry, - thresholds: Thresholds, calls: Mutex>, } impl Steps { - fn new(registry: StepRegistry, thresholds: Thresholds) -> Self { + fn new(registry: StepRegistry) -> Self { Self { registry, - thresholds, calls: Mutex::new(Vec::new()), } } @@ -184,7 +178,7 @@ impl ToolInvoker for Steps { .unwrap_or_default() .to_string(); - let answer = run_loop_step(&self.registry, &self.thresholds, &args) + let answer = run_loop_step(&self.registry, &args) .unwrap_or_else(|error| panic!("step {step} failed: {error}")); self.calls @@ -223,8 +217,8 @@ async fn run(graph: &WorkflowGraph, steps: &Arc) -> RunTrace { #[tokio::test] async fn the_merge_node_is_handed_every_arm_and_folds_them() { - let (graph, thresholds, registry) = assembled(Preset::Balanced, solving_script()); - let steps = Arc::new(Steps::new(registry, thresholds)); + let (graph, registry) = assembled(Preset::Balanced, solving_script()); + let steps = Arc::new(Steps::new(registry)); run(&graph, &steps).await; let merges = steps.calls_for("merge"); @@ -254,8 +248,8 @@ async fn the_merge_carries_the_judges_verdict_into_the_accumulator() { // and a verdict into the state its node returns; the merge reads them back // out as that arm's claim. If the round trip broke, this is the number that // would silently stay at its default. - let (graph, thresholds, registry) = assembled(Preset::Balanced, solving_script()); - let steps = Arc::new(Steps::new(registry, thresholds)); + let (graph, registry) = assembled(Preset::Balanced, solving_script()); + let steps = Arc::new(Steps::new(registry)); run(&graph, &steps).await; let merged = steps.state_after("merge", 0); @@ -269,8 +263,8 @@ async fn the_merge_carries_the_judges_verdict_into_the_accumulator() { #[tokio::test] async fn the_merge_folds_the_reflections_verdict_rather_than_dropping_it() { - let (graph, thresholds, registry) = assembled(Preset::Balanced, solving_script()); - let steps = Arc::new(Steps::new(registry, thresholds)); + let (graph, registry) = assembled(Preset::Balanced, solving_script()); + let steps = Arc::new(Steps::new(registry)); run(&graph, &steps).await; // The second pass is the one whose specialist claims the goal with an @@ -294,8 +288,8 @@ async fn a_merge_output_is_never_the_state_it_was_handed() { // The regression this file exists for. The merge used to carry its input // through untouched, which produced a green run, a bound expression, and a // routing decision made on counters no arm had moved. - let (graph, thresholds, registry) = assembled(Preset::Balanced, solving_script()); - let steps = Arc::new(Steps::new(registry, thresholds)); + let (graph, registry) = assembled(Preset::Balanced, solving_script()); + let steps = Arc::new(Steps::new(registry)); run(&graph, &steps).await; let (args, answer) = steps @@ -315,8 +309,8 @@ async fn every_node_runs_and_no_expression_resolves_to_null() { // A graph that validates and compiles is not a graph that works: a binding // reading a key nothing writes resolves to `null`, the node runs, the field // is empty, and the run reports success. - let (graph, thresholds, registry) = assembled(Preset::Balanced, solving_script()); - let steps = Arc::new(Steps::new(registry, thresholds)); + let (graph, registry) = assembled(Preset::Balanced, solving_script()); + let steps = Arc::new(Steps::new(registry)); let trace = run(&graph, &steps).await; assert!( @@ -337,8 +331,8 @@ async fn every_node_runs_and_no_expression_resolves_to_null() { #[tokio::test] async fn research_runs_once_and_the_arms_run_once_per_pass() { - let (graph, thresholds, registry) = assembled(Preset::Balanced, solving_script()); - let steps = Arc::new(Steps::new(registry, thresholds)); + let (graph, registry) = assembled(Preset::Balanced, solving_script()); + let steps = Arc::new(Steps::new(registry)); run(&graph, &steps).await; let passes = steps.calls_for("pass").len(); @@ -361,8 +355,8 @@ async fn every_arm_reads_the_attempt_and_never_the_accumulator() { // accumulator is one pass behind; an arm wired to it routes on a stale // answer. Asserted against the emitted arguments, because that is where the // wiring actually lives. - let (graph, thresholds, registry) = assembled(Preset::Balanced, solving_script()); - let steps = Arc::new(Steps::new(registry, thresholds)); + let (graph, registry) = assembled(Preset::Balanced, solving_script()); + let steps = Arc::new(Steps::new(registry)); run(&graph, &steps).await; // The attempt's second call reports differently from its first, so an arm @@ -385,8 +379,8 @@ async fn every_arm_reads_the_attempt_and_never_the_accumulator() { #[tokio::test] async fn the_run_ends_solved_with_the_report_composed_last() { - let (graph, thresholds, registry) = assembled(Preset::Balanced, solving_script()); - let steps = Arc::new(Steps::new(registry, thresholds)); + let (graph, registry) = assembled(Preset::Balanced, solving_script()); + let steps = Arc::new(Steps::new(registry)); run(&graph, &steps).await; let final_state = steps.state_after("report", 0); @@ -398,7 +392,7 @@ async fn the_run_ends_solved_with_the_report_composed_last() { #[tokio::test] async fn a_run_whose_specialists_never_answer_stops_without_claiming_success() { - let (graph, thresholds, registry) = assembled( + let (graph, registry) = assembled( Preset::Balanced, vec![ ( @@ -415,7 +409,7 @@ async fn a_run_whose_specialists_never_answer_stops_without_claiming_success() { ), ], ); - let steps = Arc::new(Steps::new(registry, thresholds)); + let steps = Arc::new(Steps::new(registry)); run(&graph, &steps).await; let final_state = steps.state_after("report", 0); @@ -432,7 +426,7 @@ async fn a_run_whose_specialists_never_answer_stops_without_claiming_success() { async fn a_run_whose_machinery_never_starts_is_blocked_rather_than_stalled() { // Infrastructure failure is not evidence about the goal, and the ladder // exits on it far sooner than it exits on being stuck. - let (graph, thresholds, registry) = assembled( + let (graph, registry) = assembled( Preset::Balanced, vec![ ( @@ -449,7 +443,7 @@ async fn a_run_whose_machinery_never_starts_is_blocked_rather_than_stalled() { ), ], ); - let steps = Arc::new(Steps::new(registry, thresholds)); + let steps = Arc::new(Steps::new(registry)); run(&graph, &steps).await; let final_state = steps.state_after("report", 0); @@ -464,7 +458,7 @@ async fn a_claim_with_no_artifact_behind_it_does_not_end_the_run() { // The anti-confabulation rule, end to end. The specialist says the magic // word on every pass and leaves nothing behind; the run must spend its // attempts rather than bank the claim. - let (graph, thresholds, registry) = assembled( + let (graph, registry) = assembled( Preset::Balanced, vec![ ( @@ -474,7 +468,7 @@ async fn a_claim_with_no_artifact_behind_it_does_not_end_the_run() { ("refuter", vec![answers("nothing to add", Vec::new())]), ], ); - let steps = Arc::new(Steps::new(registry, thresholds)); + let steps = Arc::new(Steps::new(registry)); run(&graph, &steps).await; let final_state = steps.state_after("report", 0); @@ -490,7 +484,7 @@ async fn a_salvaged_specialist_still_counts_as_work() { // Without salvage the pass reports nothing, `unproductive` increments on a // pass that produced work, and the ladder spends a diversify on a run that // was not stuck. - let (graph, thresholds, registry) = assembled( + let (graph, registry) = assembled( Preset::Balanced, vec![ ( @@ -507,7 +501,7 @@ async fn a_salvaged_specialist_still_counts_as_work() { ), ], ); - let steps = Arc::new(Steps::new(registry, thresholds)); + let steps = Arc::new(Steps::new(registry)); run(&graph, &steps).await; let final_state = steps.state_after("report", 0); @@ -528,8 +522,8 @@ async fn driving_the_loop_reaches_the_same_verdict_as_running_the_graph() { // the graph, the jq addressing, and the concurrency, and must not add a // different answer. let script = solving_script(); - let (graph, thresholds, registry) = assembled(Preset::Balanced, script.clone()); - let steps = Arc::new(Steps::new(registry, thresholds)); + let (graph, registry) = assembled(Preset::Balanced, script.clone()); + let steps = Arc::new(Steps::new(registry)); run(&graph, &steps).await; let through_engine = steps.state_after("report", 0); @@ -563,8 +557,8 @@ async fn driving_the_loop_reaches_the_same_verdict_as_running_the_graph() { #[tokio::test] async fn every_preset_runs_the_same_graph_to_a_terminal_state() { for preset in Preset::ALL { - let (graph, thresholds, registry) = assembled(preset, solving_script()); - let steps = Arc::new(Steps::new(registry, thresholds)); + let (graph, registry) = assembled(preset, solving_script()); + let steps = Arc::new(Steps::new(registry)); let trace = run(&graph, &steps).await; assert!( @@ -589,11 +583,10 @@ async fn a_step_the_registry_does_not_hold_is_an_error_rather_than_a_no_op() { // The closed step set, from the tool's side. A node naming a step nobody // registered runs green, changes nothing, and routes on a state nobody // advanced, which is the failure this returns an error for instead. - let (_, thresholds, registry) = assembled(Preset::Balanced, solving_script()); + let (_, registry) = assembled(Preset::Balanced, solving_script()); let refused = run_loop_step( ®istry, - &thresholds, &json!({ "step": "invented", "state": serde_json::to_value(LoopState::new("goal")).expect("encodes"), diff --git a/crates/tinyloops/tests/loop_run.rs b/crates/tinyloops/tests/loop_run.rs index a018fa2..2f99a95 100644 --- a/crates/tinyloops/tests/loop_run.rs +++ b/crates/tinyloops/tests/loop_run.rs @@ -40,8 +40,9 @@ use tinyflows::observability::RunObserver; use tinyflows::testkit::{MockCaps, Respond, RunTrace, RunTracer, TestHarness}; use tinyloops::{ - Advanced, Arm, ArmOutcome, ArmSet, Autonomy, CanWrite, LoopBuilder, LoopState, NoWrite, - NodeIds, RUN_LOOP_STEP, Result, STEP_MERGE, Step, StepContext, StepRegistry, Thresholds, + Advanced, Arm, ArmOutcome, ArmSet, Autonomy, CanWrite, LoopBuilder, LoopProfile, LoopState, + NoWrite, NodeIds, RUN_LOOP_STEP, Result, STEP_MERGE, Step, StepContext, StepRegistry, + Thresholds, }; /// A step body that changes nothing: every node's answer comes from the tool @@ -77,10 +78,13 @@ impl Arm for Evaluator { } /// Three attempts is enough to tell "current" from "one pass behind". -fn thresholds() -> Thresholds { - Thresholds { - max_attempts: 3, - ..Thresholds::default() +fn profile() -> LoopProfile { + LoopProfile { + thresholds: Thresholds { + max_attempts: 3, + ..Thresholds::default() + }, + ..LoopProfile::default() } } @@ -100,8 +104,9 @@ fn graph() -> WorkflowGraph { ]) .expect("two distinct arms are a valid set"); - LoopBuilder::new(thresholds(), arms, registry) + LoopBuilder::new(arms, registry) .goal("ship the release") + .profile(profile()) .autonomy(Autonomy::Unattended) .build() .expect("the fixture builds a valid graph") @@ -109,7 +114,7 @@ fn graph() -> WorkflowGraph { /// A `LoopState` as JSON, with `edit` applied. fn state_with(edit: impl FnOnce(&mut LoopState)) -> Value { - let mut state = LoopState::new("ship the release"); + let mut state = LoopState::with_profile("ship the release", profile()); edit(&mut state); serde_json::to_value(state).expect("a state encodes") } @@ -296,7 +301,7 @@ async fn pass_runs_exactly_once_per_iteration() { let steps = Arc::new(Steps::default()); let (_outcome, trace) = run(&graph, &steps, None).await; - let iterations = usize::try_from(thresholds().max_attempts).expect("a small cap fits"); + let iterations = usize::try_from(profile().thresholds.max_attempts).expect("a small cap fits"); assert_eq!(trace.steps_for("pass").len(), iterations); assert_eq!(steps.count("pass"), iterations); assert_eq!(trace.steps_for("attempt").len(), iterations); diff --git a/crates/tinyloops/tests/public_api.rs b/crates/tinyloops/tests/public_api.rs index 564c1b7..ae85d03 100644 --- a/crates/tinyloops/tests/public_api.rs +++ b/crates/tinyloops/tests/public_api.rs @@ -7,7 +7,13 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -use tinyloops::{Error, greet}; +use std::sync::Arc; + +use tinyloops::{ + Amendment, Bounds, Change, DelegateSet, Error, FixedPlan, Inline, LineSink, LoopProfile, + LoopState, Preset, Range, Recorder, Route, Scripted, ThresholdField, greet, ladder, route, + tuned_research_loop, +}; #[test] fn greeting_is_available_to_consumers() { @@ -18,3 +24,113 @@ fn greeting_is_available_to_consumers() { fn errors_are_available_to_consumers() { assert_eq!(greet("").unwrap_err(), Error::EmptyName); } + +#[test] +fn a_run_carries_the_profile_it_routes_on() { + // The whole public shape of the addressing change, from a consumer's side: + // a profile is chosen once, rides in the accumulator, and is the only thing + // `route` reads its thresholds from. + let mut state = LoopState::with_profile("goal", LoopProfile::of(Preset::Persistent)); + state.unproductive = 2; + + assert_eq!(state.profile.origin, Preset::Persistent); + assert_eq!(route(&state), Route::Retry); + + state.profile.thresholds.stuck = 1; + assert_eq!(route(&state), Route::Diversify); +} + +#[test] +fn the_ladder_a_consumer_reads_holds_no_threshold() { + let program = ladder(); + assert!(program.contains(".profile.thresholds")); + assert!(!program.contains(">= 8")); +} + +#[test] +fn a_run_may_revise_itself_only_within_its_presets_bounds() { + // The public shape of the whole adaptation surface: a preset states its + // room, a proposal is folded or refused whole, and either way the run's own + // record says what happened. + let bounds = Preset::Balanced.bounds(); + let mut profile = LoopProfile::of(Preset::Balanced); + + let allowed = Amendment::new( + "tune", + 1, + Change::Threshold { + field: ThresholdField::Stuck, + to: 3, + }, + "diversifying did not pay", + ); + assert!(profile.fold(allowed, &bounds).applied()); + assert_eq!(profile.thresholds.stuck, 3); + assert_eq!(profile.revision, 1); + + let absurd = Amendment::new( + "tune", + 2, + Change::Threshold { + field: ThresholdField::Stuck, + to: 40, + }, + "patience solves everything", + ); + assert!(!profile.fold(absurd, &bounds).applied()); + assert_eq!(profile.thresholds.stuck, 3, "a refusal changes nothing"); + assert_eq!(profile.history.len(), 2, "and is still recorded"); +} + +#[test] +fn a_deployment_can_narrow_a_preset_and_cannot_widen_one() { + let narrowed = Preset::Balanced + .bounds() + .narrow(&Bounds::none().threshold(ThresholdField::Stuck, Range::new(2, 2))); + + assert!( + narrowed + .check(&Change::Threshold { + field: ThresholdField::Stuck, + to: 4, + }) + .is_err(), + "the deployment's tighter ceiling holds", + ); + assert!(narrowed.max_amendments <= Preset::Balanced.bounds().max_amendments); +} + +#[test] +fn a_tuned_loop_is_assemblable_from_the_public_surface_alone() { + let delegates = DelegateSet::of(["prover"]); + let assembled = tuned_research_loop( + "bound the error term", + Preset::Balanced, + delegates.clone(), + Arc::new(FixedPlan::of([( + "bound", + "bound the error term", + "a proved bound", + )])), + Arc::new(Inline::of( + delegates, + [( + "prover".to_owned(), + vec![Scripted::Fails { + reason: "the sandbox would not start".to_owned(), + }], + )], + )), + ) + .expect("the tuned preset assembles"); + + let driven = assembled + .drive(&Recorder::new( + "run", + Arc::new(LineSink::new(std::io::sink())), + )) + .expect("it drives"); + + assert_eq!(driven.profile, driven.state.profile); + assert_eq!(driven.profile.origin, Preset::Balanced); +} diff --git a/crates/tinyloops/tests/routing_parity.rs b/crates/tinyloops/tests/routing_parity.rs index a2a2946..a5946a3 100644 --- a/crates/tinyloops/tests/routing_parity.rs +++ b/crates/tinyloops/tests/routing_parity.rs @@ -18,11 +18,23 @@ //! *comparison* — a `>` where the Rust reads `>=` changes when a run //! diversifies and fails nothing. //! -//! # Why exhaustive rather than sampled +//! # What "exhaustive" means here, exactly //! -//! Both sides are pure functions of a handful of small-range integers, so the -//! whole space is cheap. Sampling would buy nothing and could miss exactly the -//! off-by-one the sweep exists to catch. +//! The counter space is swept **exhaustively**, per threshold tuple, over a +//! range that reaches one past every threshold in that tuple — so the rung that +//! fires at the bound and the state just past it are both tested. +//! +//! The *threshold* space is not exhaustive, and saying so plainly matters more +//! than the word. Since thresholds are read out of the accumulator rather than +//! rendered into the graph, the ladder is one constant program and the set of +//! threshold tuples a run can reach is far larger than the shipped presets. +//! Crossing that set in full with the counter space runs to millions of +//! evaluations, each a fresh jq compile. So the sweep covers a **declared box**: +//! every shipped preset, every corner of `{0, 3}^5`, and the legacy tuples this +//! harness has always carried. That is a genuine widening over sweeping four +//! preset tuples and nothing between them, and it is chosen to contain the +//! boundaries — an operator bug, `>` where the Rust reads `>=`, shows at a +//! boundary or not at all. It is not a proof over the whole space. //! //! # Why it fails closed on `null` //! @@ -42,8 +54,8 @@ use serde_json::{Value, json}; use tinyflows::model::WorkflowGraph; use tinyloops::{ - Advanced, Arm, ArmOutcome, ArmSet, Autonomy, CanWrite, LoopBuilder, LoopState, NoWrite, Result, - STEP_MERGE, Step, StepContext, StepRegistry, Thresholds, route, + Advanced, Arm, ArmOutcome, ArmSet, Autonomy, CanWrite, LoopBuilder, LoopProfile, LoopState, + NoWrite, Preset, Result, STEP_MERGE, Step, StepContext, StepRegistry, Thresholds, route, }; /// A step body that changes nothing; only the emitted program is under test. @@ -77,8 +89,12 @@ impl Arm for Evaluator { } } -/// The graph a preset emits. -fn graph(thresholds: Thresholds) -> WorkflowGraph { +/// The graph the kernel emits. +/// +/// It takes no thresholds, and that is the point: they are addressed out of the +/// accumulator rather than rendered in, so one graph — one routing program — +/// serves every preset and every revision of one. +fn graph() -> WorkflowGraph { let mut registry = StepRegistry::new(); for name in [ "plan", "research", "attempt", STEP_MERGE, "pass", "report", "reflect", "judge", @@ -93,7 +109,7 @@ fn graph(thresholds: Thresholds) -> WorkflowGraph { ]) .expect("two distinct arms are a valid set"); - LoopBuilder::new(thresholds, arms, registry) + LoopBuilder::new(arms, registry) .goal("ship the release") .autonomy(Autonomy::Unattended) .build() @@ -128,13 +144,17 @@ fn first_disagreement( thresholds: &Thresholds, ) -> Option<(LoopState, String, String)> { let compiled = Value::String(program.to_string()); + let profile = LoopProfile { + thresholds: *thresholds, + ..LoopProfile::of(Preset::Balanced) + }; for attempts in 0..=thresholds.max_attempts + 1 { for blocked in 0..=thresholds.blocked + 1 { for unverified in 0..=thresholds.unverified + 1 { for unproductive in 0..=thresholds.stuck + 1 { for computational in 0..=thresholds.computational + 1 { for solved in [false, true] { - let mut state = LoopState::new("goal"); + let mut state = LoopState::with_profile("goal", profile.clone()); state.attempts = attempts; state.blocked = blocked; state.unverified = unverified; @@ -142,7 +162,7 @@ fn first_disagreement( state.computational = computational; state.solved = solved; - let expected = route(&state, thresholds).as_str().to_string(); + let expected = route(&state).as_str().to_string(); // Fail closed: anything that is not a string is a // program that did not answer, which is a // disagreement rather than a route. @@ -161,70 +181,129 @@ fn first_disagreement( None } -/// The presets the sweep covers, each named so a failure says which disagreed. -fn presets() -> Vec<(&'static str, Thresholds)> { - vec![ - ("default", Thresholds::default()), - ( - "impatient", - Thresholds { - max_attempts: 4, - stuck: 1, - blocked: 1, - computational: 1, - unverified: 1, - max_restarts: 1, - plan_interval: 2, - }, - ), - ( - "patient", +/// The threshold tuples the sweep covers, each named so a failure says which +/// one disagreed. +/// +/// Three sources, and each is there for a different reason. **Every shipped +/// preset**, because a preset nobody swept is a preset whose routing nobody +/// proved. **Every corner of `{0, 3}^5`**, because thresholds are now values a +/// run can move and the corners are where a comparison bug shows — a zero +/// threshold makes its rung fire on the first pass, and both sides have to +/// agree that it does. **The two legacy tuples**, because they were the +/// coverage this harness shipped with and removing them would be a silent +/// narrowing. +fn threshold_sets() -> Vec<(String, Thresholds)> { + let mut sets: Vec<(String, Thresholds)> = Preset::ALL + .into_iter() + .map(|preset| (preset.to_string(), preset.thresholds())) + .collect(); + + for corner in 0..32_u32 { + let at = |bit: u32| if corner & (1 << bit) == 0 { 0 } else { 3 }; + sets.push(( + format!("corner-{corner:02}"), Thresholds { - max_attempts: 6, - stuck: 2, - blocked: 2, - computational: 2, - unverified: 2, - max_restarts: 3, - plan_interval: 5, + max_attempts: at(0), + stuck: at(1), + blocked: at(2), + computational: at(3), + unverified: at(4), + max_restarts: 2, + plan_interval: 3, }, - ), - ] + )); + } + + sets.push(( + "impatient".to_string(), + Thresholds { + max_attempts: 4, + stuck: 1, + blocked: 1, + computational: 1, + unverified: 1, + max_restarts: 1, + plan_interval: 2, + }, + )); + sets.push(( + "patient".to_string(), + Thresholds { + max_attempts: 6, + stuck: 2, + blocked: 2, + computational: 2, + unverified: 2, + max_restarts: 3, + plan_interval: 5, + }, + )); + sets } #[test] -fn the_rendered_ladder_and_the_rust_router_agree_for_every_preset() { - for (name, thresholds) in presets() { - let program = routing_program(&graph(thresholds)); - if let Some((state, expected, actual)) = first_disagreement(&program, &thresholds) { - panic!( - "preset {name:?} disagreed: the Rust router said {expected:?} and the emitted \ - graph said {actual:?} for attempts={} blocked={} unverified={} unproductive={} \ - computational={} solved={}", - state.attempts, - state.blocked, - state.unverified, - state.unproductive, - state.computational, - state.solved, - ); - } +fn the_sweep_covers_every_preset_and_every_corner() { + let swept = threshold_sets(); + for preset in Preset::ALL { + assert!( + swept.iter().any(|(_, t)| *t == preset.thresholds()), + "{preset} is not in the parity sweep", + ); } + // 4 presets + 32 corners + 2 legacy tuples. Asserted rather than counted by + // eye, so a corner dropped from the loop above fails here. + assert_eq!(swept.len(), 38); +} + +#[test] +fn the_rendered_ladder_and_the_rust_router_agree_over_the_box() { + let program = routing_program(&graph()); + let sets = threshold_sets(); + + // One thread per tuple: every evaluation is an independent jq compile, and + // the box is large enough that running them in sequence would make the + // suite something people skip. + std::thread::scope(|scope| { + for (name, thresholds) in &sets { + let program = program.as_str(); + scope.spawn(move || { + if let Some((state, expected, actual)) = first_disagreement(program, thresholds) { + panic!( + "threshold set {name:?} disagreed: the Rust router said {expected:?} and \ + the emitted graph said {actual:?} for attempts={} blocked={} \ + unverified={} unproductive={} computational={} solved={}", + state.attempts, + state.blocked, + state.unverified, + state.unproductive, + state.computational, + state.solved, + ); + } + }); + } + }); } #[test] fn the_sweep_reaches_past_every_threshold() { - // A preset with a higher cap gets a longer sweep rather than a fixed range + // A tuple with a higher cap gets a longer sweep rather than a fixed range // that stops short and calls the untested room agreement. - for (_, thresholds) in presets() { - let mut state = LoopState::new("goal"); + let program = routing_program(&graph()); + for (name, thresholds) in threshold_sets() { + let mut state = LoopState::with_profile( + "goal", + LoopProfile { + thresholds, + ..LoopProfile::of(Preset::Balanced) + }, + ); state.attempts = thresholds.max_attempts + 1; - let program = routing_program(&graph(thresholds)); - let answered = tinyflows::expr::evaluate(&Value::String(program), &scope(&state)); + let answered = tinyflows::expr::evaluate(&Value::String(program.clone()), &scope(&state)); assert_eq!( answered.as_str(), - Some(route(&state, &thresholds).as_str()), - "the state one past the cap is inside the swept range and still agrees", + Some(route(&state).as_str()), + "{name}: the state one past the cap is inside the swept range and still agrees", ); } } @@ -243,16 +322,32 @@ fn a_ladder_that_fails_to_compile_is_caught_by_the_sweep() { } #[test] -fn the_emitted_program_is_the_generated_ladder_and_not_a_second_copy() { - let thresholds = Thresholds { - blocked: 7, - ..Thresholds::default() - }; - let program = routing_program(&graph(thresholds)); - // Every threshold in the emitted program came from the constant. - assert!(program.contains(">= 7"), "{program}"); +fn the_emitted_program_addresses_the_accumulator_and_is_not_a_second_copy() { + let program = routing_program(&graph()); + + // No threshold is rendered in. The program reads them out of the state the + // switch is handed, at the one address `route` reads them from. + assert!(program.contains(".profile.thresholds"), "{program}"); + for rendered in [">= 8", ">= 2", ">= 12", ">= 4", ">= 1"] { + assert!( + !program.contains(rendered), + "the emitted program renders a threshold: {program}", + ); + } + // The sentinel that makes a state with no profile fall through to `retry` + // rather than fire the first rung on `0 >= null`. + assert!(program.contains("4294967295"), "{program}"); assert!( - program.contains(&tinyloops::ladder(&thresholds)[1..]), + program.contains(&tinyloops::ladder()[1..]), "the switch runs the generated ladder verbatim: {program}", ); } + +#[test] +fn one_graph_serves_every_preset() { + // The graph no longer varies with the thresholds, which is what lets a run + // that revises its own resume from a checkpoint taken before it did. + let first = routing_program(&graph()); + let again = routing_program(&graph()); + assert_eq!(first, again); +} diff --git a/docs/adr/0006-thresholds-addressed-from-run-state.md b/docs/adr/0006-thresholds-addressed-from-run-state.md new file mode 100644 index 0000000..63c92c7 --- /dev/null +++ b/docs/adr/0006-thresholds-addressed-from-run-state.md @@ -0,0 +1,103 @@ +# 6. Thresholds addressed from run state, not rendered into the graph + +- **Status:** Accepted +- **Date:** 2026-08-29 + +## Context + +[ADR 0004](0004-routing-in-the-graph-steps-in-rust.md) settled that the graph +owns routing and Rust owns the steps, and it carried a fourth bullet about +*how* the routing gets its numbers: + +> Every threshold in the rendered jq is generated from the Rust `Thresholds` +> constant. No threshold literal is typed into graph JSON. + +That bullet has two halves, and only the second one turns out to be +load-bearing. The first — *generated* — means the emitted graph is a function of +the thresholds, so a threshold change is a change of topology. Three places +carry the consequence today: the loop head's `max_iterations`, the head's +`until`, and the routing switch's `expression` +(`crates/tinyloops/src/loops/builder.rs`). `GraphSignature::of` hashes each +node's `config` whole (`crates/tinyloops/src/loops/signature.rs`), so every one +of those numbers is inside the signature, and +[`loop-kernel.md`](../specs/loop-kernel.md) invariant 9 then refuses a resume +whose recorded signature does not match. + +That is correct for a run whose thresholds are fixed before it starts, and it is +fatal to one that revises them. A run that retuned itself at pass 3 records a +signature describing a graph that no longer exists, and cannot survive a crash +at pass 4. [`adaptation.md`](../specs/adaptation.md) needs the revision; +this addressing scheme forbids it. + +ADR 0004 anticipated the pressure in its own closing consequence: "Editing the +loop's control flow no longer requires editing Rust, which is what makes the +routing something an outside process — or a later `adaptive` repair — could +propose a change to." + +## Decision + +**The ladder addresses its thresholds out of the run's accumulator.** + +- The routing ladder and the head's `until` read + `.profile.thresholds.` from the state the engine already hands them. + ADR 0004's fourth bullet is amended: *no threshold literal is typed into graph + JSON* stands, and *generated from the Rust constant* is replaced by *read from + the same address the Rust reads*. +- One *ladder* therefore serves every preset, and every revision of every + preset. The jq is a fixed program rather than one rendered per `Thresholds` + value. +- The graph still carries the run's **starting** profile, in the seed + accumulator the `plan` node is handed — the same place it already carries the + goal. Two presets are two different runs and emit two different graphs, and + that is correct. What matters for a resume is that the *revision* a run makes + to itself lands in the accumulator at checkpoint time and never in the graph, + so the signature a checkpoint recorded still verifies against the graph the + builder emits. +- Every threshold read carries the fallback `// 4294967295`. `u32::MAX` is the + sentinel for "no threshold", and it makes every rung of the ladder false, so a + state with no profile falls through to `Retry`. +- The parity requirement of ADR 0004 is unchanged: the rendered jq and the Rust + router are still proved to agree, now over one program rather than one per + preset. +- The loop head's `max_iterations` stops being `thresholds.max_attempts` and + becomes the run budget's `Caps::max_iterations`. It is a runaway backstop, not + a routing decision, and `on_exceeded: "continue"` means reaching it emits on + `done` rather than failing the run. + +## Consequences + +- A run that revises its own thresholds resumes from its own checkpoint, + because the revision is state and the graph is built from the starting + profile. That is the whole reason for this decision. `src/loops/test.rs` + asserts it directly rather than by implication, and asserts the narrower + companion fact — that two presets emit the same routing programs and + different seeds — so the limit of the claim is written down rather than + assumed away. +- The sentinel is not a style choice. Under `jaq`, a missing key resolves to + `null`, `null` sorts below every number, and `0 >= null` is therefore **true** + — so an absent profile read without a fallback would fire the first rung and + route `Blocked` immediately. The fallback points every default at the cheap + outcome, which is the same rule [`routing-and-policy.md`](../specs/routing-and-policy.md) + applies to an unparseable verdict. +- Parity gets stronger in one way and weaker in another, and both are worth + stating. Stronger: the sweep now varies the thresholds themselves rather than + testing the four tuples the shipped presets happen to hold. Weaker: the space + is no longer finite by construction, so the sweep covers a declared box rather + than everything, and the implementation plan says so in its own words rather + than claiming an exhaustiveness the suite does not deliver. +- `route`, `is_terminal`, and `Outcome::classify` take the state alone. They + stay pure functions — which is what made exhaustive parity possible — and a + caller can no longer hand the router a threshold set the run is not using. +- The signature keeps meaning what it meant. Node ids, kinds, ports, edges, and + the *addressing* are still hashed; what left the hash is a set of values that + were never topology in the first place. +- This does not open the door to changing the graph's shape mid-run. Nodes, + edges, and ports stay fixed at build time and stay hashed. Adaptation moves + values, never topology. +- **Boundary check against [ADR 0003](0003-three-layer-split-with-tinyflows-adaptive.md).** + That ADR assigns exclusion lists, scoring, and promotion to + `tinyflows-adaptive`, and a reviewer will reasonably ask whether a run muting + one of its own evaluation arms is an exclusion list by another name. It is + not: the mute lives in one run's accumulator, ends with the run, and is scored + by nothing. What crosses out to `adaptive` is the finished profile as plain + data, which is the same seam every other cross-run fact uses. diff --git a/docs/plans/adaptation-tuning.md b/docs/plans/adaptation-tuning.md new file mode 100644 index 0000000..c44f82f --- /dev/null +++ b/docs/plans/adaptation-tuning.md @@ -0,0 +1,347 @@ +# Plan: the tuner and the amendments + +- **Status:** Implemented +- **Specification:** [`../specs/adaptation.md`](../specs/adaptation.md), with + the arm laws it rests on in + [`../specs/loop-kernel.md`](../specs/loop-kernel.md) and the boundary it must + not cross in + [ADR 0003](../adr/0003-three-layer-split-with-tinyflows-adaptive.md). +- **Depends on:** [`adaptation.md`](adaptation.md), entirely. Every task here + reads `LoopState::profile`, which that plan introduces. + +## Goal + +Let a run revise its own configuration, within bounds it cannot widen, on +evidence it records. One role proposes; the head folds; every proposal and every +refusal is an event; the finished profile leaves the run as data nothing here +scores. + +## Non-goals + +- Scoring an amendment against outcomes, promoting a tuned profile to a preset, + or selecting a profile for a new run. That is + `vendor/tinyflows/crates/adaptive`. This plan produces the data such a layer + would read and reads none of its state. +- Changing the graph's shape. Nodes, edges, and ports stay fixed and stay + hashed. +- Adding an arm, a step, or a delegate the loop was not built with. + +## Ordering + +Groups are **strictly ordered**: T (types and the tuner seam) → B (bounds) → +F (the fold) → E (events) → D (output and docs). T defines what a proposal is +and who may make one, B defines what may be proposed, F is where a proposal +becomes a profile, E makes both visible, D publishes. **Parallel within a +group:** T1 and T2 are independent files; F1 depends on all of T and B. + +Every task ends with `cargo test -p tinyloops ` and +`cargo clippy --all-targets --all-features -- -D warnings`. + +## Deviations this plan made, deliberately + +Three, recorded here rather than left for a reader to notice. + +**E2 landed as an assertion, not a coupling.** See the task itself. + +**Refusals are kept in the profile, not only in the event stream.** +`LoopProfile::history` holds a `Recorded` — the amendment and its `Verdict` — +rather than only the amendments that landed. That is what lets both drivers emit +`Amended` and `AmendmentRefused` by walking one list, it survives a checkpoint +with the rest of the state where an event stream does not, and it is what the +report renders. + +**`drive` now runs the `pass` step instead of re-implementing part of it.** It +was inlining the pass counter and skipping the steer clear, so the in-process +driver and the graph had already diverged by one field before this plan touched +them. Running the registered step is what makes the fold reachable from both, +and closes the divergence. + +## Task T1: what a proposal is + +**Files:** `crates/tinyloops/src/policy/amendment.rs` (new), +`src/policy/mod.rs`, `src/policy/test.rs` + +1. Failing tests: `the_amendment_wire_form_is_pinned`, + `every_change_round_trips`, and + `a_change_names_the_field_it_moves` (each `ThresholdField` maps to exactly + one `Thresholds` field, asserted by applying it and diffing). +2. Implement: + + ```rust + pub struct Amendment { + /// The role that proposed it. One value is legal; see T4. + pub proposer: String, + /// The pass that proposed it. It takes effect on the next one. + pub pass: u32, + pub change: Change, + /// The evidence, in the proposer's words. Rendered into the report. + pub because: String, + } + + pub enum Change { + Threshold(ThresholdField, u32), + Cap(CapField, u64), + MuteArm(String), + UnmuteArm(String), + } + ``` + + `ThresholdField` names the seven `Thresholds` fields, `plan_interval` + included — the re-plan cadence is a threshold like any other, and a second + spelling of it would be a second thing to keep in step. +3. `Change::apply_to(&mut LoopProfile)`, total and infallible, called only after + `Bounds::check` has passed. +4. **A closed enum, not a JSON patch.** A patch can address anything the + accumulator holds, including the counters the ladder routes on, so a tuner + able to emit one is a tuner able to write `solved`. Say so in the rustdoc; + it is the reason the type is shaped this way and it will not be obvious to + the next reader. + +## Task T2: the tuner seam + +**Files:** `crates/tinyloops/src/arm/types.rs`, `src/arm/mod.rs`, +`src/presets/steps.rs`, `src/arm/test.rs`, `src/step/types.rs` + +1. Failing tests: `a_tuner_proposes_at_most_one_amendment_a_pass`, + `a_tuner_that_proposes_nothing_folds_as_unchanged`, and a `compile_fail` + doctest, pinned to its error code, showing that an `impl Arm` has no method + that produces an `Amendment`. +2. Implement a trait distinct from `Arm`: + + ```rust + pub trait Tuner: Send + Sync { + fn name(&self) -> &'static str; + fn propose( + &self, + base: &LoopState, + report: &Value, + ctx: StepContext<'_, NoWrite>, + ) -> Result>; + } + ``` + + and a `TunerArm` adapter implementing `Arm` over it — the only code in the + crate that writes `LoopState::proposed`. +3. A separate trait rather than a third capability marker beside `CanWrite` and + `NoWrite`. The marker is the shape the `AccumulatorAccess` docs invite, and + it does not fit here: `Arm::evaluate` takes a concrete + `StepContext<'_, NoWrite>` and `ArmSet` holds `Arc`, so making the + context generic costs object safety. The adapter buys the same guarantee — + an `impl Arm` has no way to mint one — for no change to the arm surface. +4. Add `pub proposed: Option` to `LoopState`, and extend the wire-form + pin in `state/test.rs`. +5. Prove it the way this repo already proves invariant 11: a `compile_fail` + doctest with a pinned error code, matching the pair on `Observer`. There is + no `trybuild` in the tree and this does not add one. + +## Task T3: the narrative slot + +**Files:** `crates/tinyloops/src/state/types.rs`, `src/state/mod.rs`, +`src/state/test.rs` + +1. Failing tests: `an_amendment_travels_as_a_contribution` and + `merge_refuses_two_arms_proposing_an_amendment` (asserting + `Error::ContestedField { field: "amendment", .. }` names both arms). +2. Add `amendment: Option` to `Contribution`, wire it through + `claimed_from` and `apply_to`, and add a `claim` slot for it in + `LoopState::merge`. +3. `claimed_from` and `apply_to` are documented inverses whose breakage is + silent, so both halves change in this task and the round-trip is asserted. + A proposal that survives `apply_to` but is not recovered by `claimed_from` + would be dropped at the merge with nothing to report it. +4. This is invariant 2: the head remains the accumulator's sole writer, and a + second proposer in one superstep is refused rather than resolved by arrival + order. + +## Task T4: at most one tuner + +**Files:** `crates/tinyloops/src/arm/types.rs`, `src/error/mod.rs`, +`src/error/test.rs`, `src/arm/test.rs` + +1. Failing test: `two_tuners_in_one_arm_set_are_refused`, asserting the error + names both arms. +2. Add `Arm::may_tune()` defaulting to `false`, returned `true` by `TunerArm`, + and `Error::AmbiguousTuning { first, second }` with its message assertion. +3. Check it in `ArmSet::new` in the same loop that already rejects a second + concluding arm — the `may_conclude` / `AmbiguousConclusion` pair is the exact + shape, and putting the second check anywhere else is how the two drift. + +## Task B1: the bounds + +**Files:** `crates/tinyloops/src/policy/bounds.rs` (new), +`src/presets/types.rs`, `src/policy/test.rs`, `src/presets/test.rs` + +1. Failing tests: `a_change_outside_its_range_is_refused`, + `narrowing_only_ever_tightens`, `every_preset_states_its_bounds`, and + `a_bounds_a_deployment_forgot_is_the_presets`. +2. Implement `Bounds` with an inclusive range per threshold field, a ceiling per + cap field, the arms that may be muted, `muting_window`, and + `max_amendments`; `Bounds::check(&Change) -> Result<()>`; and + `Bounds::narrow(other)` clamping field by field. +3. `Preset::bounds()` beside `Preset::thresholds()`. The preset owns them: the + room a run has to revise itself is part of the methodological bet the preset + already states, so choosing a preset is choosing the bet *and* the room. +4. `narrow` is the same operation `RunBudget::narrow` already performs on caps — + `.min()` field by field — so a deployment can tighten a preset it distrusts + and can never loosen one. +5. Out of range is **refused**, never clamped. A clamped proposal reads as + accepted at the proposer and as a no-op in the state, and nothing joins the + two; the refusal is what makes a broken tuner visible. + +## Task F1: the fold + +**Files:** `crates/tinyloops/src/presets/steps.rs`, `src/presets/test.rs` + +1. Failing tests: + - `an_amendment_does_not_change_the_route_of_the_pass_that_proposed_it` + - `an_amendment_changes_the_route_of_the_next_pass` + - `a_refused_amendment_leaves_the_profile_byte_identical` + - `a_run_at_its_amendment_budget_refuses_the_next_and_continues` +2. Implement in the `Advance` step, which runs at `pass`: check + `state.proposed` against `Bounds` and `max_amendments`, apply it, bump + `revision`, append to `history`, clear `proposed`. +3. `pass` is the right node and not merely a convenient one. It is the single + exit every route enters and the only node closing the cycle, so "an + amendment takes effect on the *next* pass" is a property of where the code + sits rather than a rule someone has to remember. Folding anywhere inside the + body would make the pass's own route depend on whether the tuner finished + before the routing node — arm arrival order deciding the run. + +## Task F2: muting + +**Files:** `crates/tinyloops/src/presets/steps.rs`, `src/presets/test.rs` + +1. Failing tests: `a_muted_arm_still_runs_its_node_and_still_converges`, + `a_muted_arm_contributes_nothing`, and + `unmuting_an_undeclared_arm_is_an_error`. +2. `ArmStep::run` returns `ArmOutcome::unchanged` when the arm is in + `profile.muted`, without calling `evaluate`. Nothing about the edges changes. +3. Muting removes an arm's *work*, not its edges. Dropping a convergence edge + would leave the merge barrier waiting on an arm nothing will activate — a + hung pass rather than a saved one — and it would make the fan-out and the + fold settable independently, which is the drift `loop-kernel.md` invariant 6 + makes unrepresentable. The test asserts the merge waits on the same arm count + before and after a mute. +4. `UnmuteArm` names an arm the `ArmSet` already declares; there is no change + that adds one. + +## Task T5: the shipped tuner + +**Files:** `crates/tinyloops/src/presets/tuner.rs` (new), +`src/presets/mod.rs`, `src/presets/test.rs` + +1. Failing test: `the_rule_tuner_proposes_on_exactly_these_passes` — drive a + fixed counter sequence and assert the exact set of passes on which it + proposes, and the content of each proposal. +2. Implement `Rules`, a pure function of the counters and the arm ledger: + - a `Diversify` followed by a pass strictly more unproductive than the retry + before it proposes `Threshold(Stuck, stuck + 1)`, once; + - an arm whose contribution has been an empty delta and an empty narrative + for `muting_window` consecutive passes proposes `MuteArm(name)`; + - consecutive `blocked` passes at the bound propose a cap reduction rather + than more attempts. +3. Rule-based by default, and the rustdoc says why: a model asked mid-run + whether its own configuration is wrong has no ground truth to answer from and + every incentive to answer yes — the same pressure that makes a model claim + `Solved` on the eighth pass. A rule tuner's whole behavior is a pure function + over counters and is therefore testable at every boundary. A model tuner is + permitted behind the same trait and is bounded by the same `Bounds`, which is + the point of putting the bounds outside the proposer. +4. The `MuteArm` rule stays conservative on purpose. Bandit arm-elimination + drops an arm on a *measured reward*, and this loop has no per-arm reward — so + the rule fires on "contributed nothing measurable", never on "scored worse". + Record that in the rustdoc, and in the open questions if the distinction + turns out to matter in practice. + +## Task E1: the events + +**Files:** `crates/tinyloops/src/observe/types.rs`, `src/observe/mod.rs`, +`src/observe/test.rs` + +1. Failing tests: extend `every_event()` and let + `every_event_round_trips_through_its_wire_form` and + `every_event_names_its_pass_and_renders_to_one_line` fail on the new + variants. +2. Add `Event::Amended { pass, revision, change, because }` and + `Event::AmendmentRefused { pass, change, reason }`. +3. Four matches have no `_` arm and must be updated together: `Event::pass()`, + `Event::kind()`, `render`, and the `every_event()` fixture. +4. **The fixture is currently missing `NoteDropped`** — the variant exists, is + rendered, and is emitted from the mailbox, but no wire-form test covers it, + so a new variant can escape the test that exists to catch exactly this. Add + the missing entry in this task, and add an assertion that the fixture's + length matches the variant count so the next omission fails loudly. +5. Emit both events from the fold in F1. A run that quietly retuned itself and + then succeeded is indistinguishable in its report from a run that succeeded + as configured; the refusals matter as much as the acceptances, because forty + refused proposals is a broken tuner reporting nothing. + +## Task E2: a raised attempt ceiling has to buy passes + +**Files:** `crates/tinyloops/src/policy/test.rs`, `src/presets/test.rs` + +1. Failing test: `a_raised_attempt_ceiling_buys_passes` — a run whose + `max_attempts` is amended upward actually gets the extra passes. +2. **Landed differently from this task's original wording, deliberately.** The + plan said the head's `max_iterations` should read the bounds' ceiling. Doing + that would put the preset's configuration back into the emitted graph and + undo [`adaptation.md`](adaptation.md)'s whole point, so the head keeps the + budget's runaway backstop and the constraint moves to the other side: no + preset's bounds may raise `max_attempts` past it. Asserted in + `every_preset_states_its_bounds_and_none_of_them_permits_everything`. Same + guarantee against the silent-inert amendment, no coupling restored. + +## Task D1: the run's output + +**Files:** `crates/tinyloops/src/presets/assembled.rs`, +`src/orchestrate/steps.rs`, `src/presets/test.rs` + +1. Failing tests: `a_driven_run_reports_its_final_profile` and + `the_history_is_in_fold_order_not_proposal_order`. +2. Add `profile: LoopProfile` to `Driven`; the `report` step renders the + amendment history — what was changed, when, and on what evidence. +3. Nothing in this crate scores it. A test asserts the tuner's context type + exposes no ledger, catalogue, or score handle, so reading `adaptive` state + does not compile. This is where a run's self-observation stops and cross-run + learning starts, and a crate that scored its own amendments would be scoring + them on a single sample. + +## Task D2: exports, docs, and verification + +**Files:** `crates/tinyloops/src/lib.rs`, `src/presets/README.md`, +`tests/public_api.rs`, `README.md`, `docs/specs/adaptation.md` + +1. Re-export `Amendment`, `Change`, `Bounds`, `Tuner`, `TunerArm`, and the two + field enums. +2. Write `src/presets/README.md` covering the shipped tuner's rules, the bounds + each preset ships, and the operational constraint that a deployment may + narrow but never widen them. +3. Add a public-surface test assembling a loop with a tuner using only + `tinyloops::*`. +4. Mark `docs/specs/adaptation.md` **Implemented** and record any deliberately + untested edge case in the pull request description. + +## Verification + +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --all-targets --all-features -- -D warnings` +- [ ] `cargo build --all-targets --all-features` +- [ ] `cargo test --all-features` and `cargo test` +- [ ] `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features` +- [ ] `cargo run -p tinyloops --example basic`, + `--example simple_loop`, `--example research_loop` +- [ ] `.github/scripts/check-file-coverage.sh 90 coverage.json` + +## Invariants discharged + +| Tasks | `adaptation.md` invariant | +|---|---| +| T2, T3, T4 | 2 — one proposer, and the head is still the only writer | +| T1, B1 | 3 — closed, bounded per field, budgeted per run | +| F1 | 4 — never takes effect in the pass that proposed it | +| F2 | 6 — a muted arm still runs and still converges | +| T1, T4, F2 | 7 — an amendment adds nothing the loop was not built with | +| E1 | 8 — every amendment and every refusal is an event | +| D1 | 9 — the final profile is an output, and this crate scores nothing | + +Invariants 1 and 5 are discharged by [`adaptation.md`](adaptation.md). diff --git a/docs/plans/adaptation.md b/docs/plans/adaptation.md new file mode 100644 index 0000000..114f270 --- /dev/null +++ b/docs/plans/adaptation.md @@ -0,0 +1,343 @@ +# Plan: the loop profile + +- **Status:** Implemented +- **Specification:** [`../specs/adaptation.md`](../specs/adaptation.md), + with the addressing decision in + [ADR 0006](../adr/0006-thresholds-addressed-from-run-state.md) and the + invariant it amends in + [`../specs/loop-kernel.md`](../specs/loop-kernel.md). +- **Followed by:** [`adaptation-tuning.md`](adaptation-tuning.md), which adds + the tuner and the amendments. It depends on this plan entirely. + +## Goal + +Move every threshold out of the emitted graph and into the run's accumulator, so +that one routing program serves every preset, a threshold change stops changing +`GraphSignature`, and a run that later revises its own thresholds can resume +from its own checkpoint. (Two presets still emit two different `GraphSignature`s +— the seed accumulator carries the starting profile — but the routing they +compile is the one program either way; see Task L2.4.) + +Nothing here tunes anything. This plan lands the addressing change and the +`LoopProfile` that holds it; the profile is written once, at construction, and +no code path moves it. + +## Non-goals + +- `Amendment`, `Bounds`, the `Tuner` trait, arm muting, and the two new events. + All of that is [`adaptation-tuning.md`](adaptation-tuning.md). +- Any change to the graph's *shape*. Nodes, edges, and ports are untouched, and + stay hashed by `GraphSignature`. +- Cross-run learning. See [ADR 0003](../adr/0003-three-layer-split-with-tinyflows-adaptive.md). + +## Assumed, already landed + +`Thresholds`, `Caps`, `Preset`, `LoopState`, `LoopBuilder`, `GraphSignature`, +the two parity harnesses, and the `research_loop` preset all exist and are +green. This plan edits them; it introduces one new file. + +## Ordering + +Groups are **strictly ordered**: P (`policy/`) → S (`state/`) → L (`loops/`) → +A (`presets/`, `step/`) → V (docs and exports). P defines the type and the +program that reads it; S puts it in the accumulator; L stops rendering numbers +into the graph and re-points the two signature tests and the parity harness; +A re-points the drivers; V publishes. **Parallel within a group:** P2 and P3 +touch different functions in the same module and can be written together; every +other task depends on the one before it. + +Every task ends with `cargo test -p tinyloops ` and +`cargo clippy --all-targets --all-features -- -D warnings`. + +## A deviation this plan makes, deliberately + +The specification's invariant 5 asked for the counter space "crossed with the +declared threshold space". Measured, that is on the order of 6×10^6 jq +evaluations, each a fresh compile through `jaq`, and it would dominate the test +suite. Task L3 sweeps the counter space exhaustively against a **declared box** +of threshold tuples — every preset, the corners of `{0,3}^5`, and the three +legacy tuples — for roughly 2.4×10^5 evaluations. + +That is a genuine widening over today's harness, which tests four tuples and +nothing between them, and it is chosen to contain the boundaries where an +operator bug (`>` for `>=`) actually shows. It is not a proof over the whole +space, and the test's own doc comment must say so in those words. The +specification carries the same wording, so the two do not drift. + +## Task P1: the profile type + +**Files:** `crates/tinyloops/src/policy/profile.rs` (new), +`src/policy/mod.rs`, `src/policy/test.rs` + +1. Failing tests: `a_default_profile_carries_the_balanced_thresholds`, + `the_profile_wire_form_is_pinned` (a `serde_json::to_value` equality against + a literal, per the house rule for anything crossing a checkpoint), and + `a_profile_written_without_a_revision_deserializes`. +2. Implement in `profile.rs`: + + ```rust + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + #[serde(default)] + pub struct LoopProfile { + /// Bumped by one every time an amendment is folded. Always 0 here. + pub revision: u32, + /// The counter bounds the routing ladder reads. + pub thresholds: Thresholds, + /// The preset this profile started from. + pub origin: Preset, + } + ``` + + `Preset` needs `Serialize`/`Deserialize`/`Default` (`Balanced`) to sit in + here; it is already `Copy`, `Ord`, and `#[non_exhaustive]`, and it has + `as_str`/`parse`, so derive serde with `rename_all = "lowercase"` and pin + that its wire names match `Preset::as_str`. +3. `LoopProfile::of(preset)` and `Preset::profile()` as the two spellings of the + same construction, with the second delegating to the first. +4. It lives under `policy/` because it *is* the policy the run operates under. + The module direction is already precedented: `policy/test.rs` reads + `crate::presets::Preset::ALL`. +5. `caps`, `muted`, and `history` arrive in the follow-on plan; + `#[serde(default)]` at the container level is what absorbs the addition + without breaking a checkpoint written by this revision. + +## Task P2: the ladder reads an address + +**Files:** `crates/tinyloops/src/policy/ladder.rs`, `src/policy/test.rs` + +1. Failing tests: + - `the_ladder_reads_thresholds_out_of_the_accumulator` — two states differing + only in `profile.thresholds.stuck` route differently through the *same* + program. + - `a_state_with_no_profile_routes_retry` — evaluate the ladder against a + scope whose accumulator has no `profile` key and assert `Route::Retry`. + - `the_ladder_holds_no_threshold_literal` — no default threshold value + appears in the program text. + - The same three for `terminal_condition`. +2. Rewrite `ladder()` and `terminal_condition()` to take no arguments and emit + one fixed program: + + ```text + =(.state // .item) as $s | (($s | .profile.thresholds) // {}) as $t + | if ((($s|.blocked)//0) >= (($t|.blocked)//4294967295)) then "blocked" + elif ... + ``` + + Every threshold read carries `// 4294967295`. That is not a style choice: a + missing key is `null`, `null` sorts below every number under `jaq`, and + `0 >= null` is **true** — so an unguarded read would fire the first rung and + route `Blocked` on a state that has no profile. `u32::MAX` makes every rung + false and falls through to `Retry`, which is the cheap outcome the house rule + asks defaults to point at. +3. `evaluate_ladder(&state, loop_id)` and + `evaluate_terminal_condition(&state, loop_id)` lose their `&Thresholds` + argument. `expr_scope` is unchanged — it serializes the whole `LoopState`, so + the profile arrives at every address the engine offers. +4. Replace `the_ladder_interpolates_thresholds_rather_than_hard_coding_them` + (`policy/test.rs`) with `the_ladder_addresses_thresholds_rather_than_rendering_them`, + and do the same for `the_terminal_condition_interpolates_thresholds`. These + are the two tests that today assert the *old* invariant, so they must be + rewritten rather than deleted: the guard is still wanted, against a different + failure. + +## Task P3: the pure functions take one argument + +**Files:** `crates/tinyloops/src/policy/mod.rs`, `src/policy/types.rs`, +`src/loops/termination.rs`, `src/policy/test.rs` + +1. Failing tests: `route_reads_the_profile_it_was_handed` and + `a_route_cannot_be_computed_against_someone_elses_thresholds` (a compile-time + fact once the argument is gone; assert it by construction in the test's + comment and by the signature). +2. `route(&LoopState)`, `is_terminal(&LoopState)`, and + `Outcome::classify(&LoopState)` drop `&Thresholds` and read + `state.profile.thresholds`. +3. `TerminationCondition::{holds, evaluate, expression}` and the private + `program` / `join` drop the argument with them. +4. Purity is preserved — still a function of the state alone, which is what + makes exhaustive parity possible at all — and a caller can no longer hand the + router a threshold set the run is not using. +5. **This is a breaking public API change.** Pre-1.0, so a minor bump, and it is + named explicitly in the pull request's "public API or behavior changes" + section per `.github/PULL_REQUEST_TEMPLATE.md`. + +## Task S1: the accumulator carries the profile + +**Files:** `crates/tinyloops/src/state/types.rs`, `src/state/mod.rs`, +`src/state/test.rs` + +1. Failing tests: + - `the_wire_form_is_pinned` — extend the existing literal with `"profile"`. + - `an_older_accumulator_still_deserializes` — a state serialized without + `profile` takes the default. + - `no_arm_can_move_the_profile` — build two arm states with different + profiles, fold them, and assert the merged profile is the base's. +2. Add `pub profile: LoopProfile` to `LoopState`. +3. `LoopState::apply` carries it through from `self` untouched, in the same + block that already carries `board` and `answer`. That is the whole of + invariant 2 for this plan: no `Delta` field and no `Contribution` field + reaches the profile, so an arm cannot move it however it is wired, and the + test above is the proof rather than the promise. +4. Add `LoopState::with_profile(goal, profile)`. `LoopState::new(goal)` seeds + `LoopProfile::default()` so every existing caller keeps the balanced + thresholds it has today. + +## Task L1: the head's cap stops being a threshold + +**Files:** `crates/tinyloops/src/loops/builder.rs`, `src/loops/test.rs`, +`src/presets/assembled.rs` + +1. Failing tests: + - `the_head_is_capped_by_the_budget_not_by_the_thresholds` — two builders + differing only in `max_attempts` emit the same `max_iterations`. + - `every_preset_can_reach_its_attempt_ceiling` — for each `Preset::ALL`, + assert `preset.thresholds().max_attempts <= caps.max_iterations`. This one + fails today for a reason worth keeping: `Preset::Persistent` sets + `max_attempts: 12` against a default `Caps::max_iterations` of 8, so a + persistent run is truncated four attempts short of its own ceiling and + nothing says so. +2. `LoopBuilder` gains the run's `Caps` (a `.caps(Caps)` builder method, + defaulting to `Caps::default()`), and `head()` reads + `caps.max_iterations` for `max_iterations`. `on_exceeded: "continue"` is + unchanged, so reaching the cap emits on `done` rather than failing the run. +3. `head().config.until` becomes `self.termination.expression()` — no argument. +4. `AssembledLoop::graph()` passes `self.budget.caps()`. +5. Fix the truncation the second test exposes by raising the shipped + `Caps::max_iterations` from 8 to 12 — the largest `max_attempts` any shipped + preset asks for. Raising the backstop keeps every preset's stated bet intact, + where lowering `Persistent::max_attempts` would silently revise one. The + comment on the constant says what it must clear and why, and the test asserts + the *relationship* rather than the number, so a preset that later asks for + more fails rather than being truncated. + +## Task L2: the signature stops moving + +**Files:** `crates/tinyloops/src/loops/test.rs` + +No change to `signature.rs`. `GraphSignature::of` hashes each node's `config` +whole, and after L1 and P2 there is no threshold in any `config`. + +1. Invert the existing test that asserts two threshold sets give **different** + signatures into `the_routing_programs_are_the_same_under_every_preset`: build + a graph for each `Preset::ALL` and assert one pair of programs — the switch's + expression and the head's `until` — across all four. This is the test that + today encodes the behavior being removed, so it is rewritten, not deleted. +2. Add `the_emitted_graph_renders_no_threshold_into_a_program` — build under + deliberately distinctive thresholds and assert none of them appears in either + program, and that both address `.profile.thresholds`. +3. Add `revising_a_threshold_leaves_the_graph_untouched` — move a threshold in a + *state*, assert the route changes, and assert the signature and + `verify_resume` do not. That is the property this whole plan exists to buy, + so it gets its own named test. +4. Add `a_different_preset_is_a_different_run_but_not_a_different_ladder`, which + pins the one place the change stops short of the slogan. The **starting** + profile is seeded into the accumulator the `plan` node is handed, exactly as + the goal is, so two presets *do* emit different graphs — they are different + runs. What they no longer differ in is the routing, and a resume across a + revision depends only on that. Better to assert the limit than to leave a + reader to discover it. +5. Keep the arm-set test that asserts a *smaller* graph has a different + signature. Topology still moves the hash; only the values the ladder compares + against left it. + +## Task L3: the parity harness + +**Files:** `crates/tinyloops/tests/routing_parity.rs`, +`crates/tinyloops/src/policy/test.rs` + +1. Failing tests: `the_rendered_ladder_and_the_rust_router_agree_over_the_box` + and `the_sweep_covers_every_preset_and_every_corner`. +2. The two harnesses currently sweep **different** threshold sets — the in-crate + one derives from `Preset::ALL`, the integration one hard-codes + `default`/`impatient`/`patient`. Unify them on one function returning every + preset tuple, the corners of `{0,3}^5`, and the three legacy tuples, and + assert from both sides that the set contains every preset. +3. The switch's program is now constant, so `routing_program(&graph)` is read + once rather than per tuple, and the threshold under test is varied by + building the `LoopState` rather than by rebuilding the graph. Counters sweep + `0..=4` on all five routing fields plus `solved`; the tuple set is the outer + loop; scoping and the per-value thread stay as they are. +4. Keep `a_ladder_that_fails_to_compile_is_caught_by_the_sweep`. +5. Replace `the_emitted_program_is_the_generated_ladder_and_not_a_second_copy`'s + `contains(">= 7")` assertion with one asserting the program contains + `.profile.thresholds` and the sentinel, and still equals `ladder()` verbatim. +6. Measure the suite. If this test alone runs past ~30s, shrink the corner box + to `{0,2}^5` before touching anything else — the boundaries are still inside + it, and a sweep nobody waits for is a sweep somebody deletes. + +## Task A1: the assembled loop + +**Files:** `crates/tinyloops/src/presets/assembled.rs`, +`src/presets/types.rs`, `src/presets/test.rs` + +1. Failing tests: `a_driven_run_routes_on_the_profile_it_was_built_with` and + `the_assembled_loop_exposes_its_profile`. +2. Drop the `thresholds` field from `AssembledLoop`. `drive` seeds + `LoopState::with_profile(goal, preset.profile())` and calls `route(&state)`; + today it calls `route(&state, &self.thresholds)` against a field set once in + `new` and never re-read, which is exactly the second source this plan + removes. +3. Replace the `thresholds()` accessor with `profile()`. Anything wanting the + thresholds reads `profile().thresholds`. +4. `Outcome::classify` and `is_terminal` calls lose their argument. + +## Task A2: the step seam + +**Files:** `crates/tinyloops/src/step/mod.rs`, `src/step/types.rs`, +`src/step/test.rs` + +1. Failing test: `a_step_is_handed_the_thresholds_its_state_carries` — register + a step that asserts `ctx.thresholds()` equals the profile in the state it was + given. +2. `run_loop_step` and `StepRegistry::run{,_with}` stop taking `&Thresholds` and + read it off the decoded state, so a host cannot hand a step a threshold set + the run is not using. +3. `StepContext` keeps its `thresholds: &'a Thresholds` field. The caller copies + it out of the state before the state is moved — `Thresholds` is `Copy` — and + borrows the local. No signature on `Step`, `Arm`, or `Observer` changes. + +## Task V1: exports, docs, and callers + +**Files:** `crates/tinyloops/src/lib.rs`, `src/policy/mod.rs`, +`src/policy/ladder.rs`, `src/loops/builder.rs`, `tests/public_api.rs`, +`tests/e2e.rs`, `tests/loop_run.rs`, `examples/simple_loop.rs`, +`examples/research_loop.rs`, `README.md` + +1. Re-export `LoopProfile` from `lib.rs`, in the `policy` group beside + `Thresholds`. +2. Update every doctest that calls `ladder(&thresholds)`, `route(&state, &t)`, + `evaluate_ladder(..)`, or `terminal_condition(&t)` — they are compiled and + run by `cargo test --doc`, so they cannot be left behind. +3. Add a `LoopProfile` example to `tests/public_api.rs` using only the public + surface. +4. `README.md` mentions the preset, not the thresholds, so it needs a line only + where it describes what a checkpoint is compatible with. + +## Task V2: full verification + +Run the checklist below and read the output. In particular, `cargo test` +without `--all-features` runs in CI as its own step, and the coverage gate is +per file — `policy/profile.rs` ships with its own tests or the build fails. + +## Verification + +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --all-targets --all-features -- -D warnings` +- [ ] `cargo build --all-targets --all-features` +- [ ] `cargo test --all-features` and `cargo test` +- [ ] `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features` +- [ ] `cargo run -p tinyloops --example basic`, + `--example simple_loop`, `--example research_loop` +- [ ] `.github/scripts/check-file-coverage.sh 90 coverage.json` + +## Invariants discharged + +| Tasks | `adaptation.md` invariant | +|---|---| +| P1, P2, L1, L2 | 1 — the profile is state, not topology | +| S1 | 2 — the head is still the accumulator's only writer | +| P3, L3 | 5 — the route stays pure, parity is proved over a declared box | + +Invariants 3, 4, and 6 through 9 have nothing to attach to until there is an +amendment to bound; they are discharged by +[`adaptation-tuning.md`](adaptation-tuning.md). diff --git a/docs/specs/adaptation.md b/docs/specs/adaptation.md new file mode 100644 index 0000000..1cf7774 --- /dev/null +++ b/docs/specs/adaptation.md @@ -0,0 +1,386 @@ +# Adaptation + +- **Status:** Implemented — 2026-08-29 +- **Owner:** Maintainers +- **Related:** [`loop-kernel.md`](loop-kernel.md), + [`routing-and-policy.md`](routing-and-policy.md), + [`orchestrator.md`](orchestrator.md), + [ADR 0003](../adr/0003-three-layer-split-with-tinyflows-adaptive.md), + [ADR 0006](../adr/0006-thresholds-addressed-from-run-state.md) + +## Problem + +Every tunable in a loop is fixed before the run starts and cannot move +afterwards. `Thresholds` is a constructor argument, the `ArmSet` is a +constructor argument, `Caps` is a constructor argument, and the re-plan cadence +is a constant. A run therefore cannot act on anything it learns about *itself*. + +The run can already learn about the *work*: a reflection appends to +`LoopState::lessons`, a judge writes `LoopState::steer`, and the next attempt +reads both. What it cannot do is act on the second kind of observation, the one +about the loop rather than about the goal: + +- `stuck = 2` fires on a domain where the fourth revision is the one that lands, + so the run diversifies away from a line of attack that was working. The run + can see this — a diversify followed by a pass more unproductive than the retry + before it — and can write it in a lesson nothing reads. +- An arm returns a zero delta and an empty contribution on every pass. It costs + its share of the pass for the length of the run, and no code path can retire + it. +- A specialist times out at every brief. Salvage turns each timeout into a + usable attempt, exactly as [`orchestrator.md`](orchestrator.md) requires, and + the run keeps commissioning it because the delegate set is closed at + construction. + +A second problem compounds the first, and it is structural rather than a matter +of taste. [`loop-kernel.md`](loop-kernel.md) invariant 7 renders every threshold +into the graph's routing ladder as a literal, and invariant 9 hashes the emitted +topology into a signature a resume must match. Those two together mean **a +threshold change is a topology change**, so a run that retuned itself could not +resume from its own checkpoint: the signature it recorded describes a graph that +no longer exists. Adaptation is not merely unimplemented here; the current +addressing scheme forbids it. + +This specification defines what a run may change about itself, who may propose +it, what bounds it, and how the change reaches the next pass without breaking +either invariant. + +## Goals + +- Move every tunable out of graph literals and into a typed, versioned + `LoopProfile` carried in the accumulator, so tuning a run does not change its + topology and a tuned run resumes. +- Define `Amendment` — a closed, bounded, budgeted set of changes — as the only + way a profile moves. +- Define the role that proposes one, and make "any other role proposed an + amendment" a compile failure rather than a review finding. +- Keep the routing ladder a pure function and keep the exhaustive parity sweep, + over a threshold space that is finite because its bounds are declared. +- Emit a run's configuration history as data a cross-run layer can score, + without this crate reading a catalogue, a ledger row, or a score. + +## Non-goals + +- Cross-run learning: scoring an amendment against outcomes, promoting a tuned + profile to a preset, selecting a profile for a new run. That is + `vendor/tinyflows/crates/adaptive`, and this specification only defines the + data such a layer would read. See ADR 0003. +- Changing the graph's *shape* mid-run. Nodes, edges, and ports are fixed at + build time and stay hashed by `GraphSignature`. Adaptation moves values, never + topology. +- Prompt or brief authoring. A brief is composed by `attempt` from the board, + the steer, and the lessons, and none of that is a profile field. +- Adding a specialist or an arm the loop was not built with. See invariant 8. + +## Proposed behavior + +### `LoopProfile` + +One value, carried in `LoopState`, holding everything a run may revise about +itself: + +```rust +pub struct LoopProfile { + /// Bumped by exactly one every time an amendment is folded. + pub revision: u32, + /// The counter bounds the routing ladder reads. + pub thresholds: Thresholds, + /// The limits the meters are checked against. + pub caps: Caps, + /// Arms the `ArmSet` declares that this run is no longer paying for. + pub muted: BTreeSet, + /// Where the profile started, and every amendment since. + pub origin: Preset, + pub history: Vec, +} +``` + +There is no re-plan field, and its absence is deliberate: `Thresholds` already +carries `plan_interval` with a `plans_on` method that `orchestrator.md`'s +cadence is built on, so the cadence is amended as a threshold like any other. A +second spelling of the same number would be a second thing to keep in step. + +`Thresholds` and `Caps` are the types that exist today, unchanged. What changes +is where they are *read from*: the ladder's jq addresses +`=nodes..state.profile.thresholds.` instead of a rendered +number, and `route` takes `&state.profile.thresholds` from the same state it is +already handed. + +### `Amendment` + +```rust +pub struct Amendment { + /// The role that proposed it. One value is legal; see invariant 2. + pub proposer: &'static str, + /// The pass that proposed it. It takes effect on the next one. + pub pass: u32, + pub change: Change, + /// The evidence, in the proposer's words. Rendered into the report. + pub because: String, +} + +pub enum Change { + Threshold(ThresholdField, u32), + Cap(CapField, u64), + MuteArm(String), + UnmuteArm(String), +} +``` + +`Change` is a closed enum, not a JSON patch. A patch can address anything the +accumulator holds, including the counters the ladder routes on, so a tuner able +to emit one is a tuner able to write `solved`. + +### `Bounds` + +Each preset ships a `Bounds` alongside its starting profile: an inclusive range +per threshold field, a ceiling per cap field, a set of arms that may be muted, +`muting_window`, and `max_amendments` — the number of amendments a whole run +may fold. An amendment outside its bound is **refused**, not clamped, and the +refusal is an event. + +**The preset owns its bounds, and an embedder may narrow them but never widen +them.** As the preset's, the room a run has to revise itself is part of the +methodological bet the preset already states, so choosing a preset is choosing +the bet *and* the room; as a deployment's, a host that distrusts a preset can +tighten it without forking one. Narrowing clamps field by field, the same +operation `RunBudget::narrow` already performs on caps, and a `Bounds` a +deployment forgot to supply is the preset's, never an open one. + +Refusing rather than clamping is the difference between a tuner that is wrong +and a tuner that is wrong *and looks effective*: a clamped proposal reads as +accepted at the proposer and as a no-op in the state, and nothing joins the two. + +### The tuner + +Proposing is an evaluation arm like any other — it reads the attempt report and +the base state, it returns at most one amendment per pass, and it writes nothing +else. The shipped default is a **rule-based** tuner, a pure function of the +counters and the arm ledger: + +- A `Diversify` followed by a pass strictly more unproductive than the retry + that preceded it proposes `Threshold(Stuck, stuck + 1)`, once. +- An arm whose contribution has been an empty delta and an empty narrative for + `muting_window` consecutive passes proposes `MuteArm(name)`. +- Consecutive `blocked` passes at the bound with the same provider named + propose `Cap(...)` reductions rather than more attempts. + +It is rule-based by default because a model asked mid-run whether the loop's +own configuration is wrong has no ground truth to answer from and every +incentive to answer yes: the same pressure that makes a model claim `Solved` on +the eighth pass makes it claim the threshold was the problem. A rule tuner's +whole behavior is a pure function over counters and therefore testable at every +boundary. A model tuner is permitted behind the same trait, and it is bounded by +exactly the same `Bounds`, which is the point of putting the bounds outside the +proposer. + +## Invariants and constraints + +Nine, each stated with the failure it prevents. + +### 1. The profile is state, not topology + +Every threshold the ladder reads is addressed out of the accumulator. No +threshold is rendered into a routing program — which is the half of +[`loop-kernel.md`](loop-kernel.md) invariant 7 this keeps — and `GraphSignature` +hashes the profile's *addressing*, never a value the ladder compares against. + +The graph still carries the run's **starting** profile in the seed accumulator, +beside the goal, so two presets emit two different graphs. That is not the case +this invariant is about: two presets are two different runs. What must not +change the graph is a run revising *itself*, and it cannot, because the revision +lives in the accumulator the checkpoint holds rather than in the graph the +builder emits. + +*Why.* Under invariant 7 as written, the graph is generated *from* the +thresholds, so a change of one constant is a change of topology, and invariant 9 +then refuses to resume a checkpoint taken before it. That is correct today and +fatal to adaptation: a run that retuned itself at pass three could not survive a +crash at pass four. Reading the values out of state means one graph serves every +preset and every revision of every preset, so the signature keeps meaning what +it means — the *shape* is unchanged — while the numbers are free to move. + +**Accepting this specification requires amending invariant 7 of +[`loop-kernel.md`](loop-kernel.md)**, from "every number in the ladder is +rendered from the Rust constant" to "no number in the ladder is a literal; both +sides read the same address". The parity requirement is untouched, and +invariant 9's mechanism is untouched: it still hashes the emitted graph and +still refuses a mismatch. Its rationale is not — "every rendered threshold" no +longer names anything, since invariant 7 stops rendering one, so +`loop-kernel.md` invariant 9 is amended alongside it to say so. + +### 2. One proposer, and the head is still the only writer + +An amendment travels as a `Contribution` slot owned by exactly one arm, folded +by `LoopState::merge` under the exclusive-ownership law. The loop head remains +the accumulator's sole writer. A step context that is not the tuner's has no +slot that reaches the profile, so a second proposer does not compile. + +*Why.* This is invariant 1 and the narrative merge law applied to the field +where a silent last-writer-wins would be least detectable. Two arms proposing +different `stuck` values have no correct resolution, and picking one is arrival +order wearing a merge's clothes — the exact failure `Contribution` exists to +refuse. + +### 3. Amendments are closed, bounded per field, and budgeted per run + +`Change` is a closed enum, each variant's value is checked against `Bounds`, and +a run folds at most `max_amendments` of them. + +*Why.* An unbounded tuner has one strategy available for every difficulty, which +is to raise the threshold that is complaining. A run that can raise +`max_attempts` has no attempt ceiling; a run that can raise `stuck` never +diversifies; a run that can raise a cap has no budget. Each of those runs +completes, reports plausibly, and cost more than the run that was configured +correctly. The per-run budget bounds the second-order version, where each +individual amendment is inside its range and forty of them are not. + +### 4. An amendment never takes effect in the pass that proposed it + +The tuner proposes against the base state; the head folds the amendment at the +top of the next pass, and that pass routes on the new profile. + +*Why.* This is [`loop-kernel.md`](loop-kernel.md) invariant 3 restated for the +profile. An arm that could change a threshold and have the same pass's route +read it would make the route depend on whether the tuner finished before the +routing node, which is arm arrival order deciding the run. + +### 5. The route stays a pure function, and parity is proved over a declared box + +`route` reads the state and nothing else. The parity sweep covers the counter +space **exhaustively**, and it does so against a declared set of threshold +tuples that includes every preset and the corners of the bounds box — not +against one tuple per preset. + +*Why.* Parity today is finite because presets are finite: four tuples, swept +exhaustively. Tunable thresholds make the reachable set of ladders far larger, +and crossing it in full with the counter space is not affordable — every +evaluation is a fresh jq compile, and the product runs to millions. So the +sweep widens where it is cheap, over the thresholds a run can actually reach by +tuning, and the box it covers is written down rather than implied. What it +buys over today's harness is real: the same failure that made this invariant +worth stating — a rung comparing `>` where the Rust compares `>=` — shows up at +a boundary, and the box is chosen to contain the boundaries. What it does not +buy is a proof over the whole space, and the plan that implements it says so in +those words rather than claiming one. + +### 6. A muted arm still runs its node and still converges + +Muting removes an arm's *work*, not its edges: the node runs, returns +`ArmOutcome::unchanged`, and converges into the merge barrier as it always did. + +*Why.* [`loop-kernel.md`](loop-kernel.md) invariant 6 derives the fan-out edges +and the convergence edges from one declared list. A mute that dropped a +convergence edge would leave the barrier waiting on an arm nothing will +activate, which is a hung pass rather than a saved one — and it would make the +two edge sets settable independently, which is the drift that invariant +makes unrepresentable. + +### 7. An amendment may not add anything the loop was not built with + +`UnmuteArm` names an arm the `ArmSet` already declares. There is no change that +adds an arm, a step, a delegate, or a node. + +*Why.* The closed step set and the closed delegate set are registration-time +facts that the orchestrator's rule 2 and the kernel's node-body rule both rest +on. A run that can extend either has a closed set only until it decides +otherwise, and the failure mode is a capability nobody chose being reachable +from a rationale nobody reviewed. + +### 8. Every amendment and every refusal is an event + +`Event::Amended { pass, revision, change, because }` and +`Event::AmendmentRefused { pass, change, reason }`, and the ledger carries the +same rows. + +*Why.* A run that quietly retuned itself and then succeeded taught nobody +anything, and it is indistinguishable in its report from a run that succeeded as +configured. The configuration history is the only evidence that separates "the +loop worked" from "the loop was changed until it stopped objecting". The +refusals matter as much as the acceptances: a tuner proposing forty refused +amendments is a broken tuner reporting nothing. + +### 9. The final profile is an output, and this crate scores nothing + +`Driven` carries the final `LoopProfile` with its `history`. Nothing here reads +a catalogue, a ledger row from another run, or a score. + +*Why.* This is where a run's self-observation stops and cross-run learning +starts, and ADR 0003 puts the second in `adaptive`. Keeping the boundary means a +run *emits a proposal about its own configuration* as plain data; whether that +proposal is worth carrying into the next run is a judgement that needs outcomes +this run cannot see. A crate that scored its own amendments would be scoring +them on a single sample, which is the failure that makes a tuner confident. + +### Constraints + +- **The loop head's iteration cap is not a threshold.** It is the run budget's + `Caps::max_iterations`, a runaway backstop rendered into the graph once. + Leaving it as `max_attempts` would make raising `max_attempts` mid-run + silently inert — the amendment folds, the profile says twelve, and the head + still stops at the number it was built with. +- `LoopProfile` and `Amendment` are `serde` types with pinned representations, + per the house rule for anything that crosses a checkpoint. +- `LoopProfile` carries `#[serde(default)]` at the container level, so an + accumulator written before this existed deserializes into the preset's + starting profile rather than failing. +- The tuner is optional. A loop built without one is exactly the loop that ships + today, and no pass costs anything for the absent arm. +- Adding the profile to the accumulator does not change what the ladder reads + about the *work*: every routing field stays a plain counter. + +## Acceptance criteria + +- A graph built from two different `Thresholds` values emits the same routing + programs — the switch's expression and the head's `until` — and a run that + revises its thresholds mid-run leaves `GraphSignature` unchanged, so its own + checkpoint still resumes. +- No emitted routing program contains a threshold literal; a test asserts every + threshold value is absent from the switch's expression and the head's `until`, + and that both address `.profile.thresholds`. +- The parity sweep runs the counter space exhaustively against every preset + tuple and every corner of the bounds box, and reports the first disagreement + with the tuple, the profile revision, and the offending state. +- A preset whose `max_attempts` exceeds the loop head's iteration cap fails a + test naming both numbers, so a run cannot be truncated below the ceiling its + own thresholds declare. +- An amendment proposed at pass *n* is absent from the route computed at pass *n* + and present in the route computed at pass *n + 1*; a test asserts both. +- Proposing an amendment from any arm other than the tuner does not compile, + proved by a `compile_fail` doctest with a pinned error code, matching the pair + that already proves [`loop-kernel.md`](loop-kernel.md) invariant 11. +- Two tuners in one `ArmSet` fail at construction with an error naming both. +- An amendment outside its bound is refused, leaves `revision` unchanged, and + emits `AmendmentRefused` naming the bound; a test asserts the profile is + byte-identical before and after. +- A run that has folded `max_amendments` refuses the next one and continues, and + a test asserts the run neither stops nor routes differently on account of the + refusal. +- A muted arm's node still runs, still converges, and contributes a zero delta; + a test asserts the merge waits on the same arm count before and after a mute. +- `UnmuteArm` naming an arm outside the declared `ArmSet` is an error, and a test + asserts the run does not fall back to registering it. +- A rule-tuner test drives a fixed counter sequence and asserts the exact set of + passes on which an amendment was proposed, and its content. +- `Driven` exposes the final profile and its full history, and a test asserts the + history's order is the fold order rather than the proposal order. +- A test asserts the tuner's context type exposes no catalogue, ledger, or score + handle, so reading `adaptive` state does not compile. +- Deserializing an accumulator serialized before `profile` existed yields the + preset's starting profile. + +## Open questions + +- Whether tuning should be reachable under `Autonomy::Report` at all. A run that + takes no action but revises its own thresholds has changed what a later run + would do, from a mode whose whole promise is that it decides nothing. +- Whether an accepted amendment should force a re-plan out of cadence. A changed + `stuck` changes what "this task is going nowhere" means, and the board was + decomposed under the old meaning. +- Whether `MuteArm` should be reversible by the tuner at all, or whether an arm + a run stopped paying for should stay muted for the run. Unmuting gives the + tuner a two-state oscillation the amendment budget bounds only by exhausting + it. +- Whether the amendment history should be pinned in the memory seam so a later + run in the same scope can read it. That is the last decision before this stops + being a within-run concern and becomes `adaptive`'s. diff --git a/docs/specs/loop-kernel.md b/docs/specs/loop-kernel.md index 64383fd..1ce49e9 100644 --- a/docs/specs/loop-kernel.md +++ b/docs/specs/loop-kernel.md @@ -4,6 +4,9 @@ - **Owner:** Maintainers - **Related:** [`routing-and-policy.md`](routing-and-policy.md), [`orchestrator.md`](orchestrator.md), + [`adaptation.md`](adaptation.md), + [ADR 0006](../adr/0006-thresholds-addressed-from-run-state.md) — which amends + invariant 7, [ADR 0002](../adr/0002-loop-on-the-tinyflows-graph.md), [ADR 0004](../adr/0004-routing-in-the-graph-steps-in-rust.md) @@ -223,13 +226,25 @@ merge folds exactly the arms that list names. two facts they can drift, and the drift is silent: an arm added to the fan-out but not to the fold runs, costs its budget, and changes nothing. -### 7. Thresholds are generated, and parity is proved - -Every number in the graph's routing ladder is rendered from the Rust `Thresholds` -constant. No threshold is typed into graph JSON. A parity harness replays the -generated jq and the Rust routing function over **every** combination of the -counters across a range that reaches past every threshold, and asserts they -agree on all of them. +### 7. Thresholds are addressed, not written twice, and parity is proved + +No threshold is typed into a routing program, and none is rendered into one +either. The graph's routing ladder and the Rust routing function read every +threshold from the same address in the run's accumulator, +`.profile.thresholds.`. (The graph does carry the run's *starting* +profile, in the seed accumulator, beside the goal — a different preset is a +different run.) A +parity harness replays the emitted jq and the Rust routing function over **every** +combination of the counters across a range that reaches past every threshold, +and asserts they agree on all of them. + +*Amended by [ADR 0006](../adr/0006-thresholds-addressed-from-run-state.md).* As +originally written, this invariant required every number to be *rendered* from +the Rust `Thresholds` constant. That made the emitted graph a function of the +thresholds, so a threshold change was a topology change and invariant 9 refused +to resume across one — which is fatal to a run that revises its own thresholds +(see [`adaptation.md`](adaptation.md)). Reading them from state keeps the half +that was load-bearing, the single source, and drops the half that was not. *Why.* Two engines deciding the same run differently is invisible in a live run and obvious only in a diff. A ladder reading `>` where the Rust reads `>=` @@ -265,17 +280,26 @@ a cheap belt to the braces, and does not replace the law. ### 9. A checkpoint carries the graph's signature Every checkpoint records a signature hash over the emitted graph — node ids, -kinds, ports, edges, and every rendered threshold. Resume verifies the signature -and refuses a mismatch with a named error. Node and executor identity is -**declared** — a stable id chosen by the builder — never derived from allocation -or insertion order. - -*Why.* The graph is generated *from* the thresholds, so changing a constant -changes the topology. Resuming a checkpoint taken against the old topology onto -the new one restores state into slots that no longer mean what they meant, which -is silent corruption rather than a crash. Identity derived from allocation order -has the same failure in miniature: adding a node renumbers its neighbours and a -resumed run replays the wrong step. +kinds, ports, edges, and each node's config, addressing included. Resume +verifies the signature and refuses a mismatch with a named error. Node and +executor identity is **declared** — a stable id chosen by the builder — never +derived from allocation or insertion order. + +*Amended by [ADR 0006](../adr/0006-thresholds-addressed-from-run-state.md).* +As originally written, this invariant hashed "every rendered threshold", +because the graph was generated *from* the thresholds and a changed constant +was a changed topology. Under invariant 7 as amended, no threshold is +rendered into the graph at all, so there is no rendered value left to hash — +the addressing is what the signature now covers, and it does not move when a +run revises its own thresholds (see [`adaptation.md`](adaptation.md) +invariant 1). + +*Why.* A checkpoint resumed onto a graph whose *shape* changed restores state +into slots that no longer mean what they meant, which is silent corruption +rather than a crash. Identity derived from allocation order has the same +failure in miniature: adding a node renumbers its neighbours and a resumed +run replays the wrong step. Neither failure is about a threshold's value, so +neither is lost by no longer hashing one. ### 10. Termination is a composable condition