From 0722066d734e4c956437ffe971d591ccbd4efe92 Mon Sep 17 00:00:00 2001 From: Riley Crane Date: Tue, 1 Sep 2026 12:14:41 -0700 Subject: [PATCH] =?UTF-8?q?refactor(accumulator):=20the=20response=20IS=20?= =?UTF-8?q?the=20artifact=20=E2=80=94=20free-form=20output,=20transparent?= =?UTF-8?q?=20runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The artifact model now matches the mental model: instructions + context → model → response, persisted verbatim. Any shape the task asks for. - Delete schema.rs and validate.rs entirely: no output contract, no splice, no refusal path. complete_run is infallible; Error::Nonconforming and RunOutcome::Refused are gone. Provenance (shown_ids, coverage, truncated) stays engine-computed from the plan, never parsed from output. - spec: schema becomes a provenance label (freeform@v1 default; old chains keep channel-digest@v1, no migration). New DEFAULT_INSTRUCTIONS. - run: model_input = TASK (user instructions) + short engine guidance (context explained, citations render as links) + CONTEXT; prior version rides as '--- PRIOR VERSION (name vN) ---'. - preflight gains include_input: the Ready plan can carry the exact string the model would receive — the behind-the-curtain seam. - UI: fold cards always show their instructions; the plan box explains incremental coverage and offers the exact model input behind a disclosure; artifact header explains what 'covers' means (this run's new events) and that versions build on the prior; refused branch removed. Gates: cargo test -p buzz-accumulator 67/67, clippy -D warnings, fmt; ui tsc --noEmit + vite build clean. Live-verified on scratch daemon :4641 (preflight include_input round-trip; legacy channel-digest@v1 artifact still loads). Co-Authored-By: Claude Fable 5 Signed-off-by: Riley Crane --- crates/buzz-accumulator/src/daemon/folds.rs | 84 +++---- crates/buzz-accumulator/src/daemon/http.rs | 31 ++- crates/buzz-accumulator/src/error.rs | 14 +- crates/buzz-accumulator/src/lib.rs | 26 +- crates/buzz-accumulator/src/run.rs | 130 +++++----- crates/buzz-accumulator/src/schema.rs | 64 ----- crates/buzz-accumulator/src/spec.rs | 32 ++- crates/buzz-accumulator/src/validate.rs | 227 ------------------ crates/buzz-accumulator/ui/src/api.ts | 11 +- .../ui/src/components/ArtifactReader.tsx | 19 +- .../ui/src/components/FoldsBoard.tsx | 39 +-- 11 files changed, 202 insertions(+), 475 deletions(-) delete mode 100644 crates/buzz-accumulator/src/schema.rs delete mode 100644 crates/buzz-accumulator/src/validate.rs diff --git a/crates/buzz-accumulator/src/daemon/folds.rs b/crates/buzz-accumulator/src/daemon/folds.rs index 2aca0869e0..83bc02425e 100644 --- a/crates/buzz-accumulator/src/daemon/folds.rs +++ b/crates/buzz-accumulator/src/daemon/folds.rs @@ -28,14 +28,6 @@ pub enum RunOutcome { /// New (uncovered) signals waiting in the window. pending: usize, }, - /// Model output broke the artifact contract; nothing persisted. - /// The paid-for output is salvaged here rather than discarded. - Refused { - /// The validator's refusal. - reason: String, - /// Raw model output for inspection. - model_output: String, - }, /// A concurrent run appended this version first; nothing persisted. Unpublished { /// What happened. @@ -83,6 +75,11 @@ pub enum PreflightOutcome { estimate: crate::estimate::Estimate, /// Window actually queried, `[since, until_exclusive)`. window: (i64, i64), + /// The exact string the model would receive — the transparency seam + /// ("what's happening behind the curtain"). Present only when the + /// caller asked for it. + #[serde(skip_serializing_if = "Option::is_none")] + model_input: Option, }, } @@ -164,12 +161,14 @@ async fn plan( } /// Zero-spend preflight: what would a run over this window do, and at what -/// estimated cost? +/// estimated cost? With `include_input`, the Ready outcome carries the exact +/// string the model would receive. pub async fn preflight( store: &Store, name: &str, since: i64, until_exclusive: i64, + include_input: bool, ) -> Result { let (_, _, plan) = plan(store, name, since, until_exclusive).await?; Ok(match plan { @@ -181,6 +180,7 @@ pub async fn preflight( truncated: rp.truncated, estimate: rp.estimate, window: (since, until_exclusive), + model_input: include_input.then_some(rp.model_input), }, }) } @@ -209,18 +209,10 @@ pub async fn run_fold( .await .map_err(|e| crate::Error::Runner(format!("runner task panicked: {e}")))??; - // Post-spend: every failure from here on salvages the paid-for output. + // Post-spend: the response IS the artifact; only the version fence can + // still reject it, and that path salvages the paid-for output. let now = chrono::Utc::now().timestamp(); - let artifact = match complete_run(&spec, chain.last(), &run_plan, &output, now) { - Ok(a) => a, - Err(crate::Error::Nonconforming(reason)) => { - return Ok(RunOutcome::Refused { - reason, - model_output: output, - }); - } - Err(e) => return Err(e.into()), - }; + let artifact = complete_run(&spec, chain.last(), &run_plan, &output, now); if let Err(e) = store.insert_artifact(&artifact).await { // The (fold, version) primary key is the fence; a violation means a // concurrent writer won. Nothing forked, nothing persisted here. @@ -291,26 +283,49 @@ mod tests { #[tokio::test] async fn preflight_prices_before_any_spend() { let store = store_with_fold_and_signal().await; - let out = preflight(&store, "weekly", 0, 1_000) + let out = preflight(&store, "weekly", 0, 1_000, false) .await .expect("preflight"); match out { - PreflightOutcome::Ready { shown, pending, .. } => { + PreflightOutcome::Ready { + shown, + pending, + model_input, + .. + } => { assert_eq!(shown, 1); // Engine semantics: pending counts every new signal in the // window, including the ones that would be shown. assert_eq!(pending, 1); + assert!(model_input.is_none(), "input only rides when asked for"); } other => panic!("expected ready, got {other:?}"), } } #[tokio::test] - async fn run_persists_conforming_output_and_caches_next() { + async fn preflight_can_expose_the_exact_model_input() { + let store = store_with_fold_and_signal().await; + let out = preflight(&store, "weekly", 0, 1_000, true) + .await + .expect("preflight"); + match out { + PreflightOutcome::Ready { model_input, .. } => { + let input = model_input.expect("requested input"); + assert!(input.contains("digest the channel"), "task rides in input"); + assert!(input.contains("hello world"), "events ride in input"); + } + other => panic!("expected ready, got {other:?}"), + } + } + + #[tokio::test] + async fn run_persists_output_verbatim_and_caches_next() { let store = store_with_fold_and_signal().await; let id = "e".repeat(64); - let output = format!("# Working Context\nsummary\n\n# Log\n- hello [event:{id}]\n"); - let runner = Arc::new(FakeRunner(output)); + // Free-form output — any shape the task asked for persists verbatim. + let output = format!("Just a paragraph citing [event:{id}], no headings."); + let runner = Arc::new(FakeRunner(output.clone())); let guard = RunGuard::default(); let out = run_fold(&store, runner.clone(), &guard, "weekly", 0, 1_000) .await @@ -322,6 +337,7 @@ mod tests { assert_eq!(artifact.version, 1); assert_eq!(shown, 1); assert_eq!(artifact.shown_ids, vec![id.clone()]); + assert_eq!(artifact.output, output, "response persists verbatim"); } other => panic!("expected folded, got {other:?}"), } @@ -333,26 +349,10 @@ mod tests { assert_eq!(store.artifacts("weekly").await.expect("chain").len(), 1); } - #[tokio::test] - async fn nonconforming_output_is_refused_and_salvaged() { - let store = store_with_fold_and_signal().await; - let runner = Arc::new(FakeRunner("no headings at all".into())); - let out = run_fold(&store, runner, &RunGuard::default(), "weekly", 0, 1_000) - .await - .expect("run"); - match out { - RunOutcome::Refused { model_output, .. } => { - assert_eq!(model_output, "no headings at all"); - } - other => panic!("expected refused, got {other:?}"), - } - assert!(store.artifacts("weekly").await.expect("chain").is_empty()); - } - #[tokio::test] async fn unknown_fold_is_not_found() { let store = Store::open(":memory:").await.expect("open"); - let err = preflight(&store, "nope", 0, 10) + let err = preflight(&store, "nope", 0, 10, false) .await .expect_err("missing fold"); assert!(matches!(err, FoldError::NotFound(_))); diff --git a/crates/buzz-accumulator/src/daemon/http.rs b/crates/buzz-accumulator/src/daemon/http.rs index 9a43285378..c425e11da1 100644 --- a/crates/buzz-accumulator/src/daemon/http.rs +++ b/crates/buzz-accumulator/src/daemon/http.rs @@ -15,7 +15,7 @@ use axum::{Json, Router}; use serde_json::{json, Value}; use crate::runner::FoldRunner; -use crate::{schema, FoldSpec, Selection}; +use crate::{FoldSpec, Selection}; use super::folds::{self, FoldError, RunGuard}; use super::status::StatusRegistry; @@ -279,9 +279,9 @@ async fn get_fold( struct PutFoldBody { selection: Selection, model: String, - /// Defaults to the built-in channel digest prompt. + /// Defaults to the built-in running-digest task. instructions: Option, - /// Defaults to `channel-digest@v1` (the only schema). + /// Provenance label recorded on artifacts; defaults to `freeform@v1`. schema: Option, /// Free-form client-owned JSON, stored verbatim and never read by the /// engine (see [`FoldSpec::meta`]). @@ -298,12 +298,12 @@ async fn put_fold( selection: body.selection, schema: body .schema - .unwrap_or_else(|| schema::CHANNEL_DIGEST_V1.name.to_string()), + .unwrap_or_else(|| crate::spec::FREEFORM_SCHEMA.to_string()), model: body.model, instructions: body .instructions .filter(|i| !i.trim().is_empty()) - .unwrap_or_else(|| schema::CHANNEL_DIGEST_PROMPT.to_string()), + .unwrap_or_else(|| crate::spec::DEFAULT_INSTRUCTIONS.to_string()), meta: body.meta, }; spec.validate()?; @@ -327,15 +327,24 @@ async fn delete_fold( Ok(Json(json!({ "deleted": name }))) } +#[derive(Debug, Default, serde::Deserialize)] +struct PreflightBody { + #[serde(flatten)] + window: WindowBody, + /// When true, the Ready plan carries the exact string the model would + /// receive — the "show me what's behind the curtain" seam. + #[serde(default)] + include_input: bool, +} + async fn preflight_fold( State(s): State, Path(name): Path, - body: Option>, + body: Option>, ) -> Result, ApiError> { - let (since, until) = body - .map(|Json(w)| w.resolve()) - .unwrap_or_else(|| WindowBody::default().resolve()); - let out = folds::preflight(&s.store, &name, since, until).await?; + let Json(body) = body.unwrap_or_default(); + let (since, until) = body.window.resolve(); + let out = folds::preflight(&s.store, &name, since, until, body.include_input).await?; Ok(Json( serde_json::to_value(out).unwrap_or_else(|_| json!({})), )) @@ -454,7 +463,7 @@ mod tests { let resp = app.clone().oneshot(put).await.expect("resp"); assert_eq!(resp.status(), StatusCode::OK); let saved = body_json(resp).await; - assert_eq!(saved["saved"]["schema"], "channel-digest@v1"); + assert_eq!(saved["saved"]["schema"], "freeform@v1"); assert!(!saved["saved"]["instructions"] .as_str() .unwrap_or("") diff --git a/crates/buzz-accumulator/src/error.rs b/crates/buzz-accumulator/src/error.rs index bba0aa1f2c..a5aba4f0bb 100644 --- a/crates/buzz-accumulator/src/error.rs +++ b/crates/buzz-accumulator/src/error.rs @@ -2,21 +2,15 @@ /// Errors surfaced by the fold engine. /// -/// A [`Error::Nonconforming`] means the model's output broke the artifact -/// contract — the run is refused and nothing may be persisted. This is a -/// deliberate design rule: never build a validator that a model can only -/// satisfy by lying; every rule enforced here is one the model can genuinely -/// meet. +/// There is deliberately no "model output rejected" variant: the paid-for +/// response persists verbatim as the artifact, and provenance is +/// engine-computed from the plan, never parsed out of the output. #[derive(Debug, thiserror::Error)] pub enum Error { - /// The fold spec is invalid (bad name, empty selection, unknown schema, …). + /// The fold spec is invalid (bad name, empty selection, blank field, …). #[error("invalid fold spec: {0}")] InvalidSpec(String), - /// Model output does not conform to the artifact contract; nothing persists. - #[error("nonconforming model output: {0}")] - Nonconforming(String), - /// The model runner failed (missing binary, timeout, crash, auth sentinel). #[error("fold runner failed: {0}")] Runner(String), diff --git a/crates/buzz-accumulator/src/lib.rs b/crates/buzz-accumulator/src/lib.rs index e7ecba92c7..27df0abbb5 100644 --- a/crates/buzz-accumulator/src/lib.rs +++ b/crates/buzz-accumulator/src/lib.rs @@ -10,17 +10,16 @@ //! - **signal** — a raw relay event, never rewritten; the source of truth. //! - **selection** — a saveable query over signals (channels, authors, kinds). //! - **fold** — `artifact' = fold(artifact, new_signals, spec)`. -//! - **artifact** — compact sectioned output where every claim cites the exact -//! signals it came from; persisted locally as an immutable, append-only -//! version chain. +//! - **artifact** — the model's response, verbatim and free-form, persisted +//! locally as an immutable, append-only version chain whose provenance +//! (exactly which signals went in, and when) is engine-computed. //! - (a **lens** renders artifacts into UI; rendering is out of scope here.) //! //! The engine modules are deliberately **pure**: they plan runs, price them, -//! render transcripts, validate model output, and splice history — but perform -//! no relay I/O and hold no storage. Callers (the `buzz` CLI, the [`daemon`]) -//! fetch signals and prior artifacts, hand them to [`run::plan_run`], invoke a -//! [`runner::FoldRunner`] if the plan is ready, and finish with -//! [`run::complete_run`]. +//! and render transcripts — but perform no relay I/O and hold no storage. +//! Callers (the `buzz` CLI, the [`daemon`]) fetch signals and prior +//! artifacts, hand them to [`run::plan_run`], invoke a [`runner::FoldRunner`] +//! if the plan is ready, and finish with [`run::complete_run`]. //! //! The optional `daemon` feature (default; engine consumers opt out with //! `default-features = false`) adds the standalone headless app: it borrows a @@ -36,11 +35,10 @@ //! - Coverage records exactly the signals the model was shown — an oversized //! window stalls or truncates honestly; unread signals are never sealed //! ([`run`]). -//! - Output that breaks the artifact contract — wrong sections, a citation of -//! a signal the model was not shown, or no citation at all — is refused and -//! nothing persists ([`validate`]). -//! - Prior append-section history is retained by the engine, not the model: -//! history cannot be rewritten regardless of model output ([`validate::splice_append_sections`]). +//! - Provenance is engine-owned, never read from model output: `shown_ids` +//! and the coverage window come from the plan. The output itself is +//! free-form — `[event:]` citations in it are reader-verifiable links, +//! not a validated contract ([`artifact`]). pub mod artifact; #[cfg(feature = "daemon")] @@ -49,12 +47,10 @@ pub mod error; pub mod estimate; pub mod run; pub mod runner; -pub mod schema; pub mod selection; pub mod signal; pub mod spec; pub mod transcript; -pub mod validate; pub use artifact::ArtifactPayload; pub use error::Error; diff --git a/crates/buzz-accumulator/src/run.rs b/crates/buzz-accumulator/src/run.rs index 329e46d8db..d92c0ae107 100644 --- a/crates/buzz-accumulator/src/run.rs +++ b/crates/buzz-accumulator/src/run.rs @@ -7,21 +7,20 @@ //! call, no I/O. //! 2. The caller shows the estimate (priced before spend), invokes a //! [`crate::runner::FoldRunner`] with `RunPlan::model_input`, then -//! 3. [`complete_run`] — validate the output against the artifact contract, -//! splice history, and produce the next [`ArtifactPayload`]. Nonconforming -//! output returns an error and nothing persists. +//! 3. [`complete_run`] — record the output verbatim with engine-computed +//! provenance (exactly the shown signals and their window) as the next +//! [`ArtifactPayload`]. The output's shape is the task's business, not the +//! engine's. use std::collections::{BTreeMap, BTreeSet}; use crate::artifact::ArtifactPayload; use crate::error::Error; use crate::estimate::{self, Estimate}; -use crate::schema; use crate::selection::materialize; use crate::signal::Signal; use crate::spec::FoldSpec; use crate::transcript::{render_transcript, ShownSignal}; -use crate::validate::{splice_append_sections, validate_output}; /// Hard character budget for one run's model input (prior digest + transcript). pub const MAX_CONTEXT_CHARS: usize = 120_000; @@ -37,6 +36,17 @@ pub const MAX_SHOWN_PER_RUN: usize = 250; /// that failure priced-before-spend instead of a wasted model call. pub const MAX_PRIOR_OUTPUT_BYTES: usize = 40_000; +/// Engine note composed into every model input between the task and the +/// context: what the context is, and how citations work. The output shape is +/// deliberately unconstrained — provenance is engine-owned, so citations are +/// reader-verifiable links, not a contract. +const ENGINE_GUIDANCE: &str = "The context below is your evidence: the prior version of this \ +document (when one exists) followed by new time-ordered events. Produce the next version of \ +the document — whatever shape the task asks for. Where useful, cite the events behind a claim \ +as [event:], one id per bracket, copied in full from the SOURCE EVENT IDS list; citations \ +render as clickable links to the source, so a wrong or invented id shows readers a dead link. \ +Do not invent facts. Output only the document."; + /// The outcome of planning a run. #[derive(Debug, Clone, PartialEq)] pub enum Plan { @@ -110,7 +120,7 @@ pub fn plan_run( } let parent = match prior { Some(p) => format!( - "--- PRIOR DIGEST ({} v{}) ---\n{}", + "--- PRIOR VERSION ({} v{}) ---\n{}", spec.name, p.version, p.output ), None => String::new(), @@ -136,14 +146,11 @@ pub fn plan_run( transcript.push_str("\n\n--- SOURCE EVENT IDS ---\n"); transcript.push_str(&ids.join("\n")); } - // Instructions are the task; the schema contract is non-negotiable and - // travels with every run — otherwise custom instructions silently drop - // the structural rules that complete_run() will refuse on. + // Instructions are the task and the output is free-form; the engine only + // explains what the context is and how citations render. let model_input = format!( "--- TASK ---\n{}\n\n{}\n\n--- CONTEXT (time-ordered events) ---\n{}\n", - spec.instructions, - schema::CHANNEL_DIGEST_V1.contract_prompt(), - transcript + spec.instructions, ENGINE_GUIDANCE, transcript ); let est = estimate::estimate(&spec.model, model_input.chars().count()); Ok(Plan::Ready(RunPlan { @@ -155,25 +162,19 @@ pub fn plan_run( })) } -/// Validate model `output` for `plan` and produce the next artifact version. +/// Record model `output` for `plan` verbatim as the next artifact version. /// -/// On a structural violation (wrong H1 sections) this returns -/// [`Error::Nonconforming`] and the caller must persist nothing. Coverage is -/// computed from exactly the signals the plan showed — provenance is -/// engine-owned, not read from the output — and append-section history from -/// `prior` is spliced back mechanically, so it survives regardless of what -/// the model emitted. +/// The output's shape is the task's business — nothing about the text is +/// judged. Provenance is engine-owned: coverage is computed from exactly the +/// signals the plan showed, never from what the output claims. pub fn complete_run( spec: &FoldSpec, prior: Option<&ArtifactPayload>, plan: &RunPlan, output: &str, created_at: i64, -) -> Result { - let sch = &schema::CHANNEL_DIGEST_V1; +) -> ArtifactPayload { let shown_ids: Vec = plan.shown.iter().map(|s| s.id.clone()).collect(); - validate_output(sch, output)?; - let spliced = splice_append_sections(sch, prior.map(|p| p.output.as_str()), output); // Taint travels with the chain: once a channel's events fold in, every // later version keeps carrying that channel, even after a selection edit. let channels: BTreeSet = prior @@ -182,10 +183,10 @@ pub fn complete_run( .union(&spec.selection.channels.iter().cloned().collect()) .cloned() .collect(); - Ok(ArtifactPayload { + ArtifactPayload { fold: spec.name.clone(), version: prior.map_or(1, |p| p.version + 1), - output: spliced, + output: output.to_string(), coverage_since: plan.shown.iter().map(|s| s.created_at).min(), coverage_until: plan.shown.iter().map(|s| s.created_at).max().map(|t| t + 1), selection: spec.selection.clone(), @@ -196,7 +197,7 @@ pub fn complete_run( prompt_sha256: spec.prompt_sha256(), truncated: plan.truncated, created_at, - }) + } } #[cfg(test)] @@ -295,15 +296,21 @@ mod tests { assert!(!run.model_input.contains("--- SOURCE EVENT IDS ---")); assert!(run .model_input - .contains("--- PRIOR DIGEST (team-digest v1) ---")); - // No shown signals → citation contract not demanded, coverage stays empty. - let v2 = complete_run(&spec, Some(&prior), &run, &digest(""), 1_700_000_200).expect("v2"); + .contains("--- PRIOR VERSION (team-digest v1) ---")); + // No shown signals → nothing new to cover, coverage stays empty. + let v2 = complete_run( + &spec, + Some(&prior), + &run, + "rewritten under new task", + 1_700_000_200, + ); assert_eq!(v2.version, 2); assert!(v2.shown_ids.is_empty()); assert_eq!(v2.coverage_since, None); assert_eq!(v2.coverage_until, None); - // The engine splice still carried the prior Log forward. - assert!(v2.output.contains("- prior entry")); + // The response persists verbatim — retention is the model's job now. + assert_eq!(v2.output, "rewritten under new task"); } #[test] @@ -371,7 +378,7 @@ mod tests { panic!("expected Ready"); }; let out = digest(&format!("- new entry [event:{b}]")); - let v2 = complete_run(&spec, Some(&prior), &run, &out, 1_700_000_500).expect("v2"); + let v2 = complete_run(&spec, Some(&prior), &run, &out, 1_700_000_500); assert_eq!(v2.channels, vec!["ch1".to_string(), "ch2".to_string()]); assert_eq!(v2.selection, spec.selection); } @@ -403,9 +410,9 @@ mod tests { } #[test] - fn custom_instructions_cannot_drop_the_output_contract() { - // A task-only prompt ("what is this project about?") must still carry - // the schema's structural rules, or validation is a guaranteed refusal. + fn every_run_carries_the_task_and_the_engine_guidance() { + // The engine explains what the context is and how citations render; + // the task is the caller's, verbatim. let mut spec = spec(); spec.instructions = "what is this project trying to accomplish".to_string(); let plan = plan_run( @@ -422,9 +429,8 @@ mod tests { assert!(run .model_input .contains("what is this project trying to accomplish")); - assert!(run.model_input.contains("OUTPUT CONTRACT")); - assert!(run.model_input.contains("Working Context, Log")); - assert!(run.model_input.contains("Log is append-only")); + assert!(run.model_input.contains("whatever shape the task asks for")); + assert!(run.model_input.contains("SOURCE EVENT IDS")); } #[test] @@ -445,7 +451,7 @@ mod tests { // Coverage seals only what was shown; the rest stays pending. let cited = run.shown[0].id.clone(); let out = digest(&format!("- chunk one [event:{cited}]")); - let v1 = complete_run(&spec, None, &run, &out, 1_700_000_300).expect("v1"); + let v1 = complete_run(&spec, None, &run, &out, 1_700_000_300); assert_eq!(v1.shown_ids.len(), run.shown.len()); assert!(v1.truncated); } @@ -466,7 +472,7 @@ mod tests { panic!("expected Ready"); }; let out = digest(&format!("- said hello [event:{b}]")); - let v1 = complete_run(&spec, None, &run, &out, 1_700_000_300).expect("v1"); + let v1 = complete_run(&spec, None, &run, &out, 1_700_000_300); assert_eq!(v1.version, 1); assert_eq!(v1.shown_ids, vec![b]); assert_eq!(v1.coverage_since, Some(200)); @@ -475,7 +481,7 @@ mod tests { } #[test] - fn complete_run_splices_prior_history_and_increments_version() { + fn output_is_verbatim_and_the_prior_rides_in_the_input() { let spec = spec(); let a = hex_id('a'); let b = hex_id('b'); @@ -492,38 +498,16 @@ mod tests { let Plan::Ready(run) = plan else { panic!("expected Ready"); }; - // Model returns ONLY the new entry; engine must carry the prior Log. - let out = digest(&format!("- new entry [event:{b}]")); - let v2 = complete_run(&spec, Some(&prior), &run, &out, 1_700_000_400).expect("v2"); + // The prior version is the model's context, labeled as such… + assert!(run + .model_input + .contains("--- PRIOR VERSION (team-digest v1) ---")); + // …and the model's response IS the artifact: any shape, verbatim, no + // splice. Provenance (version, coverage) is engine bookkeeping. + let out = "A totally free-form rewrite that dropped the prior entry."; + let v2 = complete_run(&spec, Some(&prior), &run, out, 1_700_000_400); assert_eq!(v2.version, 2); - assert!(v2.output.contains("- prior entry")); - assert!(v2.output.contains("- new entry")); - } - - #[test] - fn wrong_sections_are_refused_but_citations_never_are() { - let spec = spec(); - let b = hex_id('b'); - let plan = plan_run( - &spec, - None, - &BTreeSet::new(), - vec![sig(&b, 200, "hello")], - &BTreeMap::new(), - ) - .expect("plan"); - let Plan::Ready(run) = plan else { - panic!("expected Ready"); - }; - // Wrong sections: the structure is the contract, still refused. - assert!(matches!( - complete_run(&spec, None, &run, "# Nope\n\nbody\n", 0), - Err(Error::Nonconforming(_)) - )); - // An unshown citation persists: coverage still seals exactly what the - // plan showed, independent of what the text cites. - let unshown = digest(&format!("- quoted id [event:{}]", hex_id('c'))); - let v1 = complete_run(&spec, None, &run, &unshown, 0).expect("v1"); - assert_eq!(v1.shown_ids, vec![b]); + assert_eq!(v2.output, out); + assert_eq!(v2.shown_ids, vec![b]); } } diff --git a/crates/buzz-accumulator/src/schema.rs b/crates/buzz-accumulator/src/schema.rs deleted file mode 100644 index 6b80ff067d..0000000000 --- a/crates/buzz-accumulator/src/schema.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! Artifact schemas: the structural contract a fold's output must satisfy. - -/// A schema names the exact H1 sections an artifact must have, and which of -/// them are *append* sections — sections whose history the engine retains -/// mechanically ([`crate::validate::splice_append_sections`]) so the model -/// only ever contributes new entries. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ArtifactSchema { - /// Versioned schema name, e.g. `channel-digest@v1`. - pub name: &'static str, - /// Required H1 sections, in order. - pub sections: &'static [&'static str], - /// Sections that accumulate: prior content is preserved by construction - /// and new entries must carry signal citations. - pub append_sections: &'static [&'static str], -} - -/// A bounded digest of a selection: a rewritten standing summary plus an -/// append-only, citation-carrying log. The only schema; the `schema` string -/// on specs and artifacts stays as the versioning seam for a future second -/// one. -pub const CHANNEL_DIGEST_V1: ArtifactSchema = ArtifactSchema { - name: "channel-digest@v1", - sections: &["Working Context", "Log"], - append_sections: &["Log"], -}; - -impl ArtifactSchema { - /// The output contract, rendered as prompt text. [`crate::plan_run`] - /// composes this into every model input *alongside* the spec's - /// instructions, so custom instructions focus the task but can never drop - /// the structural rules that [`crate::validate::validate_output`] will - /// enforce — otherwise a task-only prompt is a guaranteed refusal. - pub fn contract_prompt(&self) -> String { - let mut s = format!( - "OUTPUT CONTRACT (the section structure is mechanically enforced; output with \ - different H1 sections is refused and nothing persists):\nWhatever the task above \ - asks for, deliver it as markdown with exactly these H1 sections, in this order: \ - {}.\n", - self.sections.join(", ") - ); - for a in self.append_sections { - s.push_str(&format!( - "{a} is append-only: output ONLY new dated entries for this run's evidence \ - — the engine keeps the standing {a} and appends your new entries to it; \ - never repeat prior entries.\n" - )); - } - s.push_str( - "Cite the source events you actually use as [event:], one id per bracket, \ - copied in full from the SOURCE EVENT IDS list — citations render as clickable \ - links to the source, so a wrong or invented id shows readers a dead link. Do \ - not invent facts. Output only the document markdown.", - ); - s - } -} - -/// Default fold *task* instructions for [`CHANNEL_DIGEST_V1`]. The structural -/// rules live in [`ArtifactSchema::contract_prompt`], which every run gets -/// regardless of what the instructions say. -pub const CHANNEL_DIGEST_PROMPT: &str = "Maintain a bounded digest of the anchored selection. \ -Working Context is a concise standing summary rewritten in light of all evidence; the Log \ -records dated evidence entries."; diff --git a/crates/buzz-accumulator/src/spec.rs b/crates/buzz-accumulator/src/spec.rs index fe3bf8e97a..d3355f768a 100644 --- a/crates/buzz-accumulator/src/spec.rs +++ b/crates/buzz-accumulator/src/spec.rs @@ -9,12 +9,21 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::error::Error; -use crate::schema; use crate::selection::Selection; /// Maximum fold-name length (also the `d` tag value). pub const MAX_NAME_LEN: usize = 64; +/// Default schema label for new folds. The output is free-form — the label is +/// provenance on artifacts (which prompt regime produced them), not a +/// validated contract; chains created under `channel-digest@v1` keep their +/// historical label. +pub const FREEFORM_SCHEMA: &str = "freeform@v1"; + +/// Default fold *task* instructions when the caller supplies none. +pub const DEFAULT_INSTRUCTIONS: &str = "Maintain a running digest of the selection: rewrite the \ +prior version in light of the new events. Keep it concise, concrete, and factual."; + /// One fold definition. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct FoldSpec { @@ -22,7 +31,8 @@ pub struct FoldSpec { pub name: String, /// What to read. pub selection: Selection, - /// Schema name; the only one is [`crate::schema::CHANNEL_DIGEST_V1`]. + /// Schema label recorded on artifacts as provenance ([`FREEFORM_SCHEMA`] + /// by default). Output is free-form; the label is not a validated shape. pub schema: String, /// Model alias passed to the runner and priced by [`crate::estimate`]. pub model: String, @@ -55,12 +65,10 @@ impl FoldSpec { self.name ))); } - if self.schema != schema::CHANNEL_DIGEST_V1.name { - return Err(Error::InvalidSpec(format!( - "unknown schema {:?}; the only schema is {:?}", - self.schema, - schema::CHANNEL_DIGEST_V1.name - ))); + if self.schema.trim().is_empty() { + return Err(Error::InvalidSpec( + "schema label must be non-empty (it is recorded on artifacts as provenance)".into(), + )); } if self.model.trim().is_empty() { return Err(Error::InvalidSpec( @@ -116,9 +124,13 @@ mod tests { } #[test] - fn unknown_schema_and_empty_fields_are_rejected() { + fn empty_fields_are_rejected_but_any_schema_label_passes() { + // Schema is a provenance label, not a validated shape. let mut s = valid(); - s.schema = "nope@v1".to_string(); + s.schema = "my-custom-regime@v3".to_string(); + assert!(s.validate().is_ok()); + let mut s = valid(); + s.schema = " ".to_string(); assert!(s.validate().is_err()); let mut s = valid(); s.model = " ".to_string(); diff --git a/crates/buzz-accumulator/src/validate.rs b/crates/buzz-accumulator/src/validate.rs deleted file mode 100644 index a7c7e287b1..0000000000 --- a/crates/buzz-accumulator/src/validate.rs +++ /dev/null @@ -1,227 +0,0 @@ -//! Output-contract validation and the engine splice. -//! -//! Only the section STRUCTURE is validated — it is what the splice and every -//! reader depend on. Provenance is engine-owned and deterministic (the run's -//! `shown_ids` and coverage window are computed from the plan, never from -//! model output), so `[event:…]` citations in the text are best-effort links -//! for the reader, not a validated contract: a sloppy citation renders as a -//! dead chip instead of refusing a paid run. - -use crate::error::Error; -use crate::schema::ArtifactSchema; - -/// True for a line the heading grammar recognizes as an H1: `# ` followed by -/// at least one non-`#` character. -fn is_h1(line: &str) -> bool { - match line.strip_prefix("# ") { - Some(rest) => rest.chars().next().is_some_and(|c| c != '#'), - None => false, - } -} - -/// Byte offsets of the section body for H1 `name`: (start just after the -/// heading's newline, end at the next H1 line or end of input). `None` when -/// the heading is absent (a heading with no trailing newline has no body). -fn section_span(output: &str, name: &str) -> Option<(usize, usize)> { - let mut pos = 0; - let mut found: Option = None; - for line in output.split_inclusive('\n') { - let bare = line.strip_suffix('\n'); - let text = bare.unwrap_or(line); - match found { - None => { - // Heading line: `# {name}` plus optional trailing whitespace, - // and it must be newline-terminated to have a body. - let matches = text - .strip_prefix("# ") - .is_some_and(|rest| rest.trim_end() == name && rest.trim_end() == rest.trim()); - if matches && bare.is_some() { - found = Some(pos + line.len()); - } - } - Some(start) => { - if is_h1(text) { - return Some((start, pos)); - } - } - } - pos += line.len(); - } - found.map(|start| (start, output.len())) -} - -/// The trimmed body of H1 section `name`, or `""` when absent. -pub fn section(output: &str, name: &str) -> String { - match section_span(output, name) { - Some((start, end)) => output[start..end].trim().to_string(), - None => String::new(), - } -} - -/// Replace the body of H1 section `name` with `content`, leaving the rest of -/// the document byte-identical. A missing heading leaves the document -/// unchanged. -pub fn replace_section(output: &str, name: &str, content: &str) -> String { - match section_span(output, name) { - Some((start, end)) => { - format!("{}\n{}\n\n{}", &output[..start], content, &output[end..]) - } - None => output.to_string(), - } -} - -/// All H1 headings in document order (text after `# `, verbatim to line end). -pub fn headings(output: &str) -> Vec { - output - .lines() - .filter(|line| is_h1(line)) - .map(|line| line[2..].to_string()) - .collect() -} - -/// Validate model output against the artifact contract: the output must have -/// exactly the schema's H1 sections, in order. That is the whole contract — -/// the splice and the reader depend on the structure; nothing else about the -/// text is judged. Failure is [`Error::Nonconforming`]: the caller persists -/// nothing. -pub fn validate_output(schema: &ArtifactSchema, output: &str) -> Result<(), Error> { - let found = headings(output); - if found != schema.sections { - return Err(Error::Nonconforming(format!( - "output does not conform to {}: expected H1 sections {:?}, got {:?}", - schema.name, schema.sections, found - ))); - } - Ok(()) -} - -/// Rebuild append sections so prior content is retained by construction. -/// -/// The model contributes only this run's NEW entries; the engine prepends the -/// prior section verbatim. A model that repeats the prior section anyway is -/// deduplicated (exact-substring strip). History cannot be rewritten or -/// dropped no matter what the model outputs — and the model never has to -/// re-emit an ever-growing log, which kept run latency flat along the chain. -pub fn splice_append_sections( - schema: &ArtifactSchema, - previous_output: Option<&str>, - output: &str, -) -> String { - let Some(previous) = previous_output.filter(|p| !p.is_empty()) else { - return output.to_string(); - }; - let mut doc = output.to_string(); - for name in schema.append_sections { - let prior = section(previous, name); - if prior.is_empty() { - continue; - } - let model_part = section(&doc, name); - let new_part = if model_part.contains(&prior) { - model_part.replacen(&prior, "", 1) - } else { - model_part - }; - let new_part = new_part.trim(); - let combined = if new_part.is_empty() { - prior - } else { - format!("{prior}\n{new_part}") - }; - doc = replace_section(&doc, name, &combined); - } - doc -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::schema::CHANNEL_DIGEST_V1; - - fn id(c: char) -> String { - std::iter::repeat_n(c, 64).collect() - } - - fn digest(log_body: &str) -> String { - format!("# Working Context\n\nSummary.\n\n# Log\n\n{log_body}\n") - } - - #[test] - fn section_and_replace_roundtrip() { - let doc = digest("- entry one"); - assert_eq!(section(&doc, "Log"), "- entry one"); - assert_eq!(section(&doc, "Working Context"), "Summary."); - assert_eq!(section(&doc, "Missing"), ""); - let replaced = replace_section(&doc, "Log", "- swapped"); - assert_eq!(section(&replaced, "Log"), "- swapped"); - assert_eq!(section(&replaced, "Working Context"), "Summary."); - } - - #[test] - fn headings_ignore_h2_and_capture_in_order() { - let doc = "# One\n\n## nested\n\n# Two\nbody\n"; - assert_eq!(headings(doc), vec!["One", "Two"]); - } - - #[test] - fn heading_mismatch_is_refused() { - let out = "# Wrong\n\nx\n\n# Log\n\n- e\n"; - let err = validate_output(&CHANNEL_DIGEST_V1, out); - assert!(matches!(err, Err(Error::Nonconforming(_)))); - } - - #[test] - fn conforming_sections_pass_regardless_of_citations() { - // Provenance is engine-owned (shown_ids + coverage window on the - // artifact); citations in the text are best-effort links, never a - // refusal. Unshown, missing, and malformed citations all pass — - // an unresolvable chip in the reader beats losing a paid run. - assert!(validate_output(&CHANNEL_DIGEST_V1, &digest("")).is_ok()); - assert!(validate_output(&CHANNEL_DIGEST_V1, &digest("- no citation here")).is_ok()); - let unshown = digest(&format!("- quoted from message text [event:{}]", id('c'))); - assert!(validate_output(&CHANNEL_DIGEST_V1, &unshown).is_ok()); - let malformed = digest("- sloppy [event:abc123]"); - assert!(validate_output(&CHANNEL_DIGEST_V1, &malformed).is_ok()); - } - - #[test] - fn splice_retains_prior_history_when_model_omits_it() { - let prior = digest(&format!("- old entry [event:{}]", id('a'))); - let fresh = digest(&format!("- new entry [event:{}]", id('b'))); - let doc = splice_append_sections(&CHANNEL_DIGEST_V1, Some(&prior), &fresh); - let log = section(&doc, "Log"); - assert_eq!( - log, - format!( - "- old entry [event:{}]\n- new entry [event:{}]", - id('a'), - id('b') - ) - ); - } - - #[test] - fn splice_dedupes_a_repeated_prior_section() { - let prior = digest(&format!("- old entry [event:{}]", id('a'))); - let repeat_plus_new = digest(&format!( - "- old entry [event:{}]\n- new entry [event:{}]", - id('a'), - id('b') - )); - let doc = splice_append_sections(&CHANNEL_DIGEST_V1, Some(&prior), &repeat_plus_new); - let log = section(&doc, "Log"); - assert_eq!(log.matches("- old entry").count(), 1); - assert!(log.contains("- new entry")); - } - - #[test] - fn splice_with_no_new_entries_keeps_prior_exactly() { - let prior = digest(&format!("- old entry [event:{}]", id('a'))); - let empty_log = digest(""); - let doc = splice_append_sections(&CHANNEL_DIGEST_V1, Some(&prior), &empty_log); - assert_eq!( - section(&doc, "Log"), - format!("- old entry [event:{}]", id('a')) - ); - } -} diff --git a/crates/buzz-accumulator/ui/src/api.ts b/crates/buzz-accumulator/ui/src/api.ts index 9067943e7e..228083280f 100644 --- a/crates/buzz-accumulator/ui/src/api.ts +++ b/crates/buzz-accumulator/ui/src/api.ts @@ -105,6 +105,9 @@ export type Preflight = truncated: boolean; estimate: Estimate; window: [number, number]; + /** The exact string the model would receive; present only when the + * preflight asked for it (`include_input`). */ + model_input?: string; }; export type Artifact = { @@ -138,7 +141,6 @@ export type ArtifactSummary = { export type RunOutcome = | { status: "cached" } | { status: "stalled"; reason: string; pending: number } - | { status: "refused"; reason: string; model_output: string } | { status: "unpublished"; reason: string; model_output: string } | { status: "folded"; @@ -226,8 +228,11 @@ export const api = { ) => request<{ saved: FoldSpec }>(`/folds/${name}`, { method: "PUT", ...json(body) }), deleteFold: (name: string) => request<{ deleted: string }>(`/folds/${name}`, { method: "DELETE" }), - preflight: (name: string, window: TimeWindow) => - request(`/folds/${name}/preflight`, post(window)), + preflight: (name: string, window: TimeWindow, includeInput = false) => + request( + `/folds/${name}/preflight`, + post({ ...window, include_input: includeInput }), + ), run: (name: string, window: TimeWindow) => request(`/folds/${name}/run`, post(window)), artifacts: (name: string) => diff --git a/crates/buzz-accumulator/ui/src/components/ArtifactReader.tsx b/crates/buzz-accumulator/ui/src/components/ArtifactReader.tsx index b06d4e721c..3ab1f610b7 100644 --- a/crates/buzz-accumulator/ui/src/components/ArtifactReader.tsx +++ b/crates/buzz-accumulator/ui/src/components/ArtifactReader.tsx @@ -10,8 +10,8 @@ type Props = { onVersion: (v: number) => void; }; -/** Renders one artifact version: Working Context + Log with citation chips, - * a version scrubber, and a pinned-evidence rail. */ +/** Renders one artifact version: the model's free-form response with citation + * chips, a version scrubber, and a pinned-evidence rail. */ export function ArtifactReader({ fold, version, onVersion }: Props) { const [pinned, setPinned] = useState([]); @@ -50,12 +50,21 @@ export function ArtifactReader({ fold, version, onVersion }: Props) { {a && (
- {fmtTime(a.created_at)} · model {a.model} · {a.shown_ids.length} shown - {a.truncated ? " · chunked window" : ""} + {fmtTime(a.created_at)} · model {a.model} · folded{" "} + {a.shown_ids.length} new event(s) + {a.truncated ? " · chunked (more remained pending)" : ""} {a.coverage_since !== null && a.coverage_until !== null && ( <> {" "} - · covers {fmtTime(a.coverage_since)} → {fmtTime(a.coverage_until)} + · this run's new events span {fmtTime(a.coverage_since)} →{" "} + {fmtTime(a.coverage_until)} + + )} + {a.version > 1 && ( + <> + {" "} + · built on v{a.version - 1} (earlier versions' events aren't + re-read — they ride along in the prior version) )}
diff --git a/crates/buzz-accumulator/ui/src/components/FoldsBoard.tsx b/crates/buzz-accumulator/ui/src/components/FoldsBoard.tsx index e693ca97bf..400abe42f1 100644 --- a/crates/buzz-accumulator/ui/src/components/FoldsBoard.tsx +++ b/crates/buzz-accumulator/ui/src/components/FoldsBoard.tsx @@ -84,7 +84,7 @@ function CreateFold({ selection }: { selection: Selection }) {