From 2f165d8d1e92f48a15af72813d5973906d58707f Mon Sep 17 00:00:00 2001 From: Eureka Date: Tue, 10 Nov 2026 14:47:47 +0000 Subject: [PATCH 01/72] huginn sources --front: search only a repo's front-page files SourceQuery gains `front` (serde default false, so an old client's request decodes as before). The one owner of the list is sources::FRONT_PAGE_FILES; the Qdrant filter matches `path` against it with MatchAny on the existing keyword index, composed with the `repo` condition. The CLI prints the citation line range as before. Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-state/src/bin/huginn/main.rs | 9 ++- crates/eureka-state/tests/cli.rs | 12 ++++ crates/huginn-daemon/src/daemon.rs | 2 +- crates/huginn-daemon/src/index/fakes.rs | 7 ++- crates/huginn-daemon/src/index/qdrant.rs | 55 +++++++++++++++++-- crates/huginn-daemon/src/index/tests.rs | 2 +- crates/huginn-daemon/src/sources/mod.rs | 11 +++- crates/huginn-daemon/src/sources/tests.rs | 28 +++++++++- .../huginn-daemon/tests/live_index_smoke.rs | 1 + crates/huginn-mind/src/wire.rs | 26 ++++++++- .../huginn.mind_request.v1.schema.json | 6 +- 11 files changed, 141 insertions(+), 18 deletions(-) diff --git a/crates/eureka-state/src/bin/huginn/main.rs b/crates/eureka-state/src/bin/huginn/main.rs index f63599c..e33f83c 100644 --- a/crates/eureka-state/src/bin/huginn/main.rs +++ b/crates/eureka-state/src/bin/huginn/main.rs @@ -104,6 +104,10 @@ enum Command { /// Look in this repo alone (its forge name). #[arg(long)] repo: Option, + /// Look only in repos' front-page files (README.md, README, readme.md, + /// README.markdown, CLAUDE.md, AGENTS.md at the repo root). + #[arg(long)] + front: bool, /// How many chunks (default 10, at most 50). #[arg(long)] top_k: Option, @@ -273,9 +277,10 @@ fn run(settings: &Settings, command: Command) -> Result { Command::Sources { text, repo, + front, top_k, json, - } => sources(settings, text, repo, top_k, json), + } => sources(settings, text, repo, front, top_k, json), Command::Recipe(Recipe::Blocked { spec_id, page }) => { blocked(settings, &spec_id, page.cursor, page.documents) } @@ -434,6 +439,7 @@ fn sources( settings: &Settings, text: String, repo: Option, + front: bool, top_k: Option, json: bool, ) -> Result { @@ -441,6 +447,7 @@ fn sources( text: Line(text), top_k: top_k.unwrap_or(DEFAULT_TOP_K), repo: repo.map(Short), + front, }; match settings.ask(|instance| HuginnMindRequest::SearchSources { instance: instance.clone(), diff --git a/crates/eureka-state/tests/cli.rs b/crates/eureka-state/tests/cli.rs index 0156131..538f70a 100644 --- a/crates/eureka-state/tests/cli.rs +++ b/crates/eureka-state/tests/cli.rs @@ -1283,6 +1283,7 @@ impl huginn_daemon::sources::SourceIndex for Desk { &mut self, _vector: &[f32], repo: Option<&str>, + front: bool, limit: u32, _within: Duration, ) -> anyhow::Result> { @@ -1290,6 +1291,7 @@ impl huginn_daemon::sources::SourceIndex for Desk { .held .iter() .filter(|payload| repo.is_none_or(|repo| payload.repo == repo)) + .filter(|payload| !front || huginn_daemon::sources::FRONT_PAGE_FILES.contains(&payload.path.as_str())) .take(limit as usize) .map(|payload| huginn_daemon::sources::SourceRow { score: 0.9, payload: payload.clone() }) .collect()) @@ -1370,6 +1372,16 @@ fn sources_prints_a_citation_for_a_search_answered_by_a_daemon() { assert!(!found.out.contains("README") && !found.out.contains("Other"), "{}", found.out); } +/// `huginn sources --front` end to end: only the front-page file answers, in +/// the repo asked for, with the line range to cite. +#[test] +fn sources_front_prints_only_the_front_page_with_its_lines() { + let server = source_daemon("d1", "d1"); + let found = at(server.addr, &["sources", "what is this", "--front", "--repo", "Demo"], None); + assert_eq!(found.code, 0, "{}", found.err); + assert_eq!(found.out.trim_end(), "Demo:README.md:1-2 @abcdef1 0.900\n# Demo", "{}", found.out); +} + /// A daemon whose embedder is not the model the index was built under refuses /// the search naming both, and the CLI exits 1 with that word. #[test] diff --git a/crates/huginn-daemon/src/daemon.rs b/crates/huginn-daemon/src/daemon.rs index 25d32a5..6311f50 100644 --- a/crates/huginn-daemon/src/daemon.rs +++ b/crates/huginn-daemon/src/daemon.rs @@ -636,7 +636,7 @@ pub(crate) mod tests { let mut daemon = Daemon::new(mind, SourcesIndex { asked: Arc::clone(&asked), hits: hits.clone() }); let request = |instance: &str, top_k: u32| HuginnMindRequest::SearchSources { instance: slug(instance), - query: SourceQuery { text: huginn_mind::eureka_pipeline::Line("find".into()), top_k, repo: None }, + query: SourceQuery { text: huginn_mind::eureka_pipeline::Line("find".into()), top_k, repo: None, front: false }, }; let Handled::Searching(search) = daemon.handle(request(INSTANCE, 5), now()) else { panic!("a well-formed source search is a search") }; diff --git a/crates/huginn-daemon/src/index/fakes.rs b/crates/huginn-daemon/src/index/fakes.rs index 705090b..b62ce8b 100644 --- a/crates/huginn-daemon/src/index/fakes.rs +++ b/crates/huginn-daemon/src/index/fakes.rs @@ -355,13 +355,14 @@ impl SourceIndex for FakeSources { Ok(()) } - fn search_sources(&mut self, vector: &[f32], repo: Option<&str>, limit: u32, _within: Duration) -> Result> { + fn search_sources(&mut self, vector: &[f32], repo: Option<&str>, front: bool, limit: u32, _within: Duration) -> Result> { let mut state = self.state.lock().unwrap(); state.searches.push((repo.map(str::to_owned), limit)); let mut rows: Vec = state .points .values() .filter(|point| repo.is_none_or(|repo| point.payload.repo == repo)) + .filter(|point| !front || crate::sources::FRONT_PAGE_FILES.contains(&point.payload.path.as_str())) .map(|point| SourceRow { score: cosine(vector, &point.vector), payload: point.payload.clone() }) .collect(); rows.sort_by(|a, b| b.score.total_cmp(&a.score)); @@ -386,7 +387,7 @@ impl SourceIndex for FakeIndex { fn upsert_sources(&mut self, points: &[SourcePoint]) -> Result<()> { self.sources.upsert_sources(points) } - fn search_sources(&mut self, vector: &[f32], repo: Option<&str>, limit: u32, within: Duration) -> Result> { - self.sources.search_sources(vector, repo, limit, within) + fn search_sources(&mut self, vector: &[f32], repo: Option<&str>, front: bool, limit: u32, within: Duration) -> Result> { + self.sources.search_sources(vector, repo, front, limit, within) } } diff --git a/crates/huginn-daemon/src/index/qdrant.rs b/crates/huginn-daemon/src/index/qdrant.rs index bc8c6dc..b897dc9 100644 --- a/crates/huginn-daemon/src/index/qdrant.rs +++ b/crates/huginn-daemon/src/index/qdrant.rs @@ -7,7 +7,7 @@ use std::time::Duration; use anyhow::{Context, Result, ensure}; use serde_json::{Value, json}; -use crate::sources::{COLLECTION, DescribedSources, SourceIndex, SourcePayload, SourcePoint, SourceRow, SourcesMeta}; +use crate::sources::{COLLECTION, FRONT_PAGE_FILES, DescribedSources, SourceIndex, SourcePayload, SourcePoint, SourceRow, SourcesMeta}; use super::{CollectionMeta, INDEX_CALL_TIMEOUT, Described, Hit, Http, Method, Point, VectorIndex}; @@ -175,6 +175,19 @@ impl VectorIndex for QdrantIndex { } } +/// The payload filter of a source search: the repo, and the front-page paths +/// (exact matches on the keyword-indexed `path`), both when both are asked. +fn source_filter(repo: Option<&str>, front: bool) -> Option { + let mut must = Vec::new(); + if let Some(repo) = repo { + must.push(json!({ "key": "repo", "match": { "value": repo } })); + } + if front { + must.push(json!({ "key": "path", "match": { "any": FRONT_PAGE_FILES } })); + } + (!must.is_empty()).then(|| json!({ "must": must })) +} + fn payload_json(payload: &SourcePayload) -> Value { json!({ "repo": payload.repo, @@ -298,12 +311,12 @@ impl SourceIndex for QdrantIndex { Ok(()) } - fn search_sources(&mut self, vector: &[f32], repo: Option<&str>, limit: u32, within: Duration) -> Result> { + fn search_sources(&mut self, vector: &[f32], repo: Option<&str>, front: bool, limit: u32, within: Duration) -> Result> { let collection = self.sources.clone(); let http = self.http.bounded(within); let mut body = json!({ "vector": vector, "limit": limit, "with_payload": true, "with_vector": false }); - if let Some(repo) = repo { - body["filter"] = json!({ "must": [{ "key": "repo", "match": { "value": repo } }] }); + if let Some(filter) = source_filter(repo, front) { + body["filter"] = filter; } let (_, answer) = self.ask_with(&http, Method::Post, &format!("/collections/{collection}/points/search"), Some(&body), &[200])?; @@ -319,3 +332,37 @@ impl SourceIndex for QdrantIndex { .collect() } } + +#[cfg(test)] +mod source_filter_tests { + use super::*; + + /// No ask, no filter; a repo alone is the repo's match. + #[test] + fn no_ask_is_no_filter_and_a_repo_is_its_own_match() { + assert_eq!(source_filter(None, false), None); + assert_eq!(source_filter(Some("Huginn"), false), Some(json!({ "must": [{ "key": "repo", "match": { "value": "Huginn" } }] }))); + } + + /// The front filter matches the path against exactly the front-page files + /// and nothing else, and composes with the repo: both conditions must hold. + #[test] + fn the_front_filter_is_exactly_the_front_files_and_composes_with_the_repo() { + let any = |filter: &Value| -> Vec { + let conditions = filter["must"].as_array().unwrap(); + let path: Vec<&Value> = conditions.iter().filter(|c| c["key"] == "path").collect(); + assert_eq!(path.len(), 1); + path[0]["match"]["any"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_owned()).collect() + }; + let front = source_filter(None, true).unwrap(); + assert_eq!(any(&front), ["README.md", "README", "readme.md", "README.markdown", "CLAUDE.md", "AGENTS.md"]); + assert_eq!(front["must"].as_array().unwrap().len(), 1, "no repo condition when none was asked"); + + let both = source_filter(Some("Zyphos"), true).unwrap(); + assert_eq!(any(&both), any(&front)); + let repos: Vec<&Value> = both["must"].as_array().unwrap().iter().filter(|c| c["key"] == "repo").collect(); + assert_eq!(repos.len(), 1); + assert_eq!(repos[0]["match"]["value"], "Zyphos"); + assert_eq!(both["must"].as_array().unwrap().len(), 2); + } +} diff --git a/crates/huginn-daemon/src/index/tests.rs b/crates/huginn-daemon/src/index/tests.rs index 16af983..0488510 100644 --- a/crates/huginn-daemon/src/index/tests.rs +++ b/crates/huginn-daemon/src/index/tests.rs @@ -1995,7 +1995,7 @@ fn the_sink_owns_the_searchs_deadline_and_tells_the_loop() { } fn ask_sources(sink: &mut WorkerSink, text: &str) -> Result { - let query = SourceQuery { text: huginn_mind::eureka_pipeline::Line(text.into()), top_k: 5, repo: None }; + let query = SourceQuery { text: huginn_mind::eureka_pipeline::Line(text.into()), top_k: 5, repo: None, front: false }; >::search_sources(sink, &query) } diff --git a/crates/huginn-daemon/src/sources/mod.rs b/crates/huginn-daemon/src/sources/mod.rs index ea88095..6efd040 100644 --- a/crates/huginn-daemon/src/sources/mod.rs +++ b/crates/huginn-daemon/src/sources/mod.rs @@ -38,6 +38,10 @@ use git::{BLOB_BYTES_MAX, Mirror, TreeFile}; /// The collection this organ's source index lives in. pub const COLLECTION: &str = "huginn_sources"; +/// The paths, exactly and at a repo's root, that are its front page: what an +/// agent reads first to learn what the repo is. The only owner of the list. +pub const FRONT_PAGE_FILES: [&str; 6] = ["README.md", "README", "readme.md", "README.markdown", "CLAUDE.md", "AGENTS.md"]; + /// What the collection says its corpus is. pub const CORPUS: &str = "forge:GameCult"; @@ -121,8 +125,9 @@ pub trait SourceIndex { fn delete_by(&mut self, repo: &str, paths: Option<&[String]>, keeping: Option<&str>) -> Result<()>; /// Writes chunks, acknowledged before it returns. fn upsert_sources(&mut self, points: &[SourcePoint]) -> Result<()>; - /// The `limit` nearest chunks, best first, in `repo` alone when given. - fn search_sources(&mut self, vector: &[f32], repo: Option<&str>, limit: u32, within: Duration) -> Result>; + /// The `limit` nearest chunks, best first, in `repo` alone when given, and + /// among the `FRONT_PAGE_FILES` alone when `front`. + fn search_sources(&mut self, vector: &[f32], repo: Option<&str>, front: bool, limit: u32, within: Duration) -> Result>; } /// A repo the forge lists as indexable. @@ -459,7 +464,7 @@ pub fn search(embedder: &mut E, index: &mut S, quer ensure!(vectors.len() == 1, "the embedder returned {} vectors for one query", vectors.len()); let vector = vectors.remove(0); ensure!(vector.len() == identity.dimensions as usize, "the embedder returned a query vector of {} where the collection holds {}", vector.len(), identity.dimensions); - let rows = index.search_sources(&vector, query.repo.as_ref().map(|repo| repo.0.as_str()), query.top_k, budget.left()?)?; + let rows = index.search_sources(&vector, query.repo.as_ref().map(|repo| repo.0.as_str()), query.front, query.top_k, budget.left()?)?; Ok(SourceHits { indexed_under: SourceModel { name: built.model, digest: built.model_digest }, hits: rows diff --git a/crates/huginn-daemon/src/sources/tests.rs b/crates/huginn-daemon/src/sources/tests.rs index 7e62905..d32d1ae 100644 --- a/crates/huginn-daemon/src/sources/tests.rs +++ b/crates/huginn-daemon/src/sources/tests.rs @@ -500,8 +500,12 @@ fn skips_binary_lfs_oversize() { assert_eq!(rig.index.cursors().get("Demo"), Some(&c)); } +fn front_query(text: &str, top_k: u32, repo: Option<&str>) -> SourceQuery { + SourceQuery { front: true, ..query(text, top_k, repo) } +} + fn query(text: &str, top_k: u32, repo: Option<&str>) -> SourceQuery { - SourceQuery { text: Line(text.into()), top_k, repo: repo.map(|repo| Short(repo.into())) } + SourceQuery { text: Line(text.into()), top_k, repo: repo.map(|repo| Short(repo.into())), front: false } } /// A daemon whose embedder is not the one the collection was built under @@ -555,6 +559,28 @@ fn a_source_search_answers_held_chunks_within_a_repo_and_a_limit() { assert_eq!(asked, format!("Instruct: {SOURCE_QUERY_INSTRUCTION}\nQuery: alpha")); } +/// A front search answers only the front-page files at a repo's root, in every +/// repo or in the one asked for; a nested README or a look-alike is not one. +#[test] +fn a_front_search_answers_only_root_front_pages_within_the_repo_asked() { + let (one, two) = (Remote::new(), Remote::new()); + one.commit(&[("README.md", b"one front\n"), ("docs/README.md", b"nested\n"), ("README.md.bak", b"bak\n"), ("a.rs", b"code\n"), ("AGENTS.md", b"agents\n")], &[]); + two.commit(&[("CLAUDE.md", b"two front\n"), ("src/lib.rs", b"lib\n")], &[]); + let mut rig = Rig::new(); + rig.converged(&mut FakeForge::of(&[("One", &one), ("Two", &two)])); + let within = Duration::from_secs(5); + let mut held = |query: &SourceQuery| { + let mut found: Vec<(String, String)> = + search(&mut rig.embedder, &mut rig.index, query, within).unwrap().hits.into_iter().map(|hit| (hit.repo, hit.path)).collect(); + found.sort(); + found + }; + let pair = |repo: &str, path: &str| (repo.to_owned(), path.to_owned()); + assert_eq!(held(&front_query("front", 50, None)), [pair("One", "AGENTS.md"), pair("One", "README.md"), pair("Two", "CLAUDE.md")]); + assert_eq!(held(&front_query("front", 50, Some("Two"))), [pair("Two", "CLAUDE.md")]); + assert_eq!(held(&query("front", 50, Some("Two"))).len(), 2, "without front, the repo's other files answer too"); +} + /// Chunk ids are the content address: the same blob chunked the same way is /// the same point, and any part of the address changes it. #[test] diff --git a/crates/huginn-daemon/tests/live_index_smoke.rs b/crates/huginn-daemon/tests/live_index_smoke.rs index a3d2274..9516ca9 100644 --- a/crates/huginn-daemon/tests/live_index_smoke.rs +++ b/crates/huginn-daemon/tests/live_index_smoke.rs @@ -181,6 +181,7 @@ fn a_small_repo_is_refreshed_into_a_scratch_source_collection_and_found() { text: huginn_mind::eureka_pipeline::Line("what does this repository do".into()), top_k: 3, repo: None, + front: false, }; huginn_daemon::sources::search(&mut embedder, &mut index, &query, Duration::from_secs(60)).ok() }); diff --git a/crates/huginn-mind/src/wire.rs b/crates/huginn-mind/src/wire.rs index 309bb6e..a64948d 100644 --- a/crates/huginn-mind/src/wire.rs +++ b/crates/huginn-mind/src/wire.rs @@ -181,13 +181,17 @@ pub struct RepoMoved { /// The most hits a source search answers. pub const SOURCE_TOP_K_MAX: u32 = 50; -/// A source search: the text to find chunks near, how many, and optionally the -/// one forge repo (by name) to look in. +/// A source search: the text to find chunks near, how many, optionally the +/// one forge repo (by name) to look in, and whether to look only in repos' +/// front-page files (`FRONT_PAGE_FILES` at the repo root). An old client's +/// request has no `front` and means false. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct SourceQuery { pub text: Line, pub top_k: u32, pub repo: Option, + #[serde(default)] + pub front: bool, } impl SourceQuery { @@ -415,7 +419,7 @@ mod tests { }, HuginnMindRequest::SearchSources { instance: slug(INSTANCE), - query: SourceQuery { text: Line("reconcile".into()), top_k: 5, repo: Some(Short("Huginn".into())) }, + query: SourceQuery { text: Line("reconcile".into()), top_k: 5, repo: Some(Short("Huginn".into())), front: true }, }, HuginnMindRequest::RootsTouched { instance: slug(INSTANCE) }, ] @@ -516,6 +520,7 @@ mod tests { text: Line(text.into()), top_k, repo: repo.map(|repo| Short(repo.into())), + front: false, }; let field = |query: SourceQuery| match query.check() { Err(MindRefusal::SelectionInvalid { field, .. }) => field, @@ -529,6 +534,21 @@ mod tests { assert_eq!(field(query("x", 5, Some(" "))), "repo"); } + /// An old client's search has no `front`: it decodes as the whole-index + /// search it asked for. + #[test] + fn a_source_query_encoded_without_front_decodes_as_false() { + #[derive(Serialize)] + struct OldQuery { + text: Line, + top_k: u32, + repo: Option, + } + let old = OldQuery { text: Line("x".into()), top_k: 3, repo: None }; + let bytes = rmp_serde::to_vec_named(&old).unwrap(); + assert!(!rmp_serde::from_slice::(&bytes).unwrap().front); + } + /// A new client reads an old daemon's page: the five fields it had, no /// `scores`. This is what keeps rollback by flip safe. #[test] diff --git a/schemas/cultnet/huginn.mind_request.v1.schema.json b/schemas/cultnet/huginn.mind_request.v1.schema.json index 8f6f080..58844d6 100644 --- a/schemas/cultnet/huginn.mind_request.v1.schema.json +++ b/schemas/cultnet/huginn.mind_request.v1.schema.json @@ -2788,9 +2788,13 @@ "maxLength": 64 }, "SourceQuery": { - "description": "A source search: the text to find chunks near, how many, and optionally the\none forge repo (by name) to look in.", + "description": "A source search: the text to find chunks near, how many, optionally the\none forge repo (by name) to look in, and whether to look only in repos'\nfront-page files (`FRONT_PAGE_FILES` at the repo root). An old client's\nrequest has no `front` and means false.", "type": "object", "properties": { + "front": { + "type": "boolean", + "default": false + }, "repo": { "anyOf": [ { From be35c72a168f3c39465c218025c0fb0afaf59aee Mon Sep 17 00:00:00 2001 From: Eureka Date: Tue, 10 Nov 2026 15:15:48 +0000 Subject: [PATCH 02/72] eureka-pipeline: the desk_answer kind, tagged DeskOutcome and its key One kind, key :desk_answer:.n, one tagged outcome (Counted, Revision, Retraction, Refused). Published schema, index row, golden line and the PipelineKind enum in sibling schemas regenerated. Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-pipeline/golden/envelopes.txt | 2 + crates/eureka-pipeline/src/lib.rs | 94 ++++++++- ...iphany.pipeline.desk_answer.v2.schema.json | 187 ++++++++++++++++++ .../epiphany.pipeline.finding.v2.schema.json | 3 +- ...epiphany.pipeline.follow_up.v2.schema.json | 3 +- .../epiphany.pipeline.idea.v2.schema.json | 3 +- .../epiphany.pipeline.memory.v2.schema.json | 3 +- .../epiphany.pipeline.question.v2.schema.json | 3 +- .../epiphany.pipeline.reply.v2.schema.json | 3 +- .../epiphany.pipeline.research.v2.schema.json | 3 +- ...piphany.pipeline.resolution.v2.schema.json | 3 +- .../epiphany.pipeline.ruling.v2.schema.json | 3 +- .../epiphany.pipeline.run.v2.schema.json | 3 +- .../epiphany.pipeline.thought.v2.schema.json | 3 +- schemas/cultnet/index.json | 11 ++ 15 files changed, 314 insertions(+), 13 deletions(-) create mode 100644 schemas/cultnet/epiphany.pipeline.desk_answer.v2.schema.json diff --git a/crates/eureka-pipeline/golden/envelopes.txt b/crates/eureka-pipeline/golden/envelopes.txt index 916532e..01bb38b 100644 --- a/crates/eureka-pipeline/golden/envelopes.txt +++ b/crates/eureka-pipeline/golden/envelopes.txt @@ -11,6 +11,7 @@ # a failing test pass: a diff here is a wire change and needs its own ruling. # cut_spec.reads-and-symbol and finding.location-symbol were appended after 44c010c added CodeLocation.symbol and PipelineCutSpec.reads (ruling context-pack-read-anchors); no earlier line changed. # target.repos was appended after 9298371 added PipelineTarget.repos (ruling campaign-repo-set-home); no earlier line changed. +# sample.desk_answer was inserted after the sample corpus when the desk_answer kind was added (ruling eureka-substrate:ruling:desk-answer-kind-tagged-outcome); no earlier line changed. sample.campaign eureka-state:campaign:self epiphany.pipeline.campaign.v2 EpiphanyPipelineCampaignDocument 9185a4736c7567ac657572656b612d7374617465a57469746c65b5457572656b6120706970656c696e65207374617465a57265706f7391b147616d6543756c742f4570697068616e79ae776f726b696e675f6272616e6368bb636f6465782f657572656b612d706970656c696e652d7374617465aa7461726765745f646f6384a470617468d9256e6f7465732f657572656b612d706970656c696e652d73746174652d7461726765742e6d64aa73746172745f6c696e6501a8656e645f6c696e6509a6636f6d6d6974a83566393832323864 sample.target eureka-state:target:r2 epiphany.pipeline.target.v2 EpiphanyPipelineTargetDocument 9186a863616d706169676eac657572656b612d7374617465a87265766973696f6e02aa696e76617269616e74739182a56c6162656cab6d696e642d61646d697473a973746174656d656e74b64f6e6c792061646d697373696f6e207772697465732eac6e6f745f696e5f73636f706591ac4576652062726f7773696e67b963616e6f6e6963616c5f696d706c656d656e746174696f6e7391a743756c744c6962a3646f6384a470617468d9256e6f7465732f657572656b612d706970656c696e652d73746174652d7461726765742e6d64aa73746172745f6c696e6501a8656e645f6c696e6509a6636f6d6d6974a83566393832323864 sample.question eureka-state:question:Q1 epiphany.pipeline.question.v2 EpiphanyPipelineQuestionDocument 9189a863616d706169676eac657572656b612d7374617465a56c6162656ca25131a57469746c65b357686f206f776e73207468652073746174653fa87175657374696f6eb357686f206f776e73207468652073746174653fa76f7074696f6e739282a56c6162656ca141a474657874ab616e20696e7374616e636582a56c6162656ca142a474657874a661207265706fab7265636f6d6d656e646564a141a7646570656e647391a6437574203361a97261697365645f696e82a46b696e64a743757453706563a26964bf657572656b612d73746174653a6375745f737065633a6375742d33612e7231a861736b65645f6f6eaa323032362d30392d3135 @@ -31,6 +32,7 @@ sample.research eureka-state:research:p1.scope epiphany.pipeline.research.v2 Epi sample.idea eureka-state:idea:p1.i1 epiphany.pipeline.idea.v2 EpiphanyPipelineIdeaDocument 9189a863616d706169676eac657572656b612d7374617465a470617373a27031a56c6162656ca26931a57469746c65b24f6e65206e6f74652070657220636c61696da469646561d9204b65657020612070617373206173206d616e7920736d616c6c206e6f7465732ea974726164656f666673b74d6f726520646f63756d656e747320746f206c6973742ea872657374735f6f6e9182a46b696e64a85265736561726368a26964be657572656b612d73746174653a72657365617263683a70312e73636f7065a6746f776172649182a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131ab70726f706f7365645f6f6eaa323032362d30392d3135 sample.reply yggdrasil:reply:mind-2026-10-09-1-r1 epiphany.pipeline.reply.v2 EpiphanyPipelineReplyDocument 9185a8696e7374616e6365a979676764726173696ca56c6162656cb46d696e642d323032362d31302d30392d312d7231a372756e82a46b696e64a352756ea26964bf79676764726173696c3a72756e3a6d696e642d323032362d31302d30392d31a7616e73776572739182a46b696e64a8466f6c6c6f775570a26964d923657572656b612d73746174653a666f6c6c6f775f75703a61736b2d7468652d73656c66a474657874d93054686520736861646572207061737320697320646f6e653b2074686520736f756e647320617265204c6962627927732e sample.thought yggdrasil:thought:t1793000000 epiphany.pipeline.thought.v2 EpiphanyPipelineThoughtDocument 91de0014a8696e7374616e6365a979676764726173696ca56c6162656cab7431373933303030303030a474657874d93d54686520736f756e647320617265204c6962627927733b2074686520736861646572732077657265206e6576657220746865206861726420706172742ea572656164739182a46b696e64a64d656d6f7279a26964d92879676764726173696c3a6d656d6f72793a323032362d31302d3331743034313230302d74302d6d30af706572736f6e615f72656365697074b06d696e642d636f6d6d69742d30613162a86964656e74697479be457572656b612c207368652f6865722c20706c61696e2d73706f6b656e2ea7636f6e74657874d92056616c7565733a20636f686572656e6365206f7665722076656c6f636974792ea87374696d756c7573bd4c696262792061736b65642061626f75742074686520736f756e64732eaf70726f6a6563746f725f6d6f64656cb070726f6a6563746f723a6c6174657374aa70726f6a656374696f6ed92949206b656570207475726e696e67206f7665722077686f20686f6c64732074686520736f756e64732eaa626173655f6d6f64656cab626173653a6c6174657374b474656d70657261747572655f7065726d696c6c65cd03e8ae6d696e5f705f7065726d696c6c6532aa6d61785f746f6b656e73cca0aa63616e6469646174657392d93d54686520736f756e647320617265204c6962627927733b2074686520736861646572732077657265206e6576657220746865206861726420706172742eaf41207365636f6e642064726966742eb06e6561726573745f7065726d696c6c6592cd0136cd0366a663686f73656e00ac70726f6a6563746f725f6d73cd1068ac636f6e74696e7565725f6d73cd238cae70726f6d70745f76657273696f6ebd67686f73746c696768743d633433656235343b20776f7264733d313830 +sample.desk_answer eureka-state:desk_answer:Q1.n1 epiphany.pipeline.desk_answer.v2 EpiphanyPipelineDeskAnswerDocument 918aa863616d706169676eac657572656b612d7374617465a4666f726bb8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501ac6f7074696f6e735f68617368d94063636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363a663686f696365a161a46e6f7465b853686172652069742c207769746820746865206d61726b2eab616e7377657265645f6174b4323032362d31312d31305431353a30303a30305aa8616e737765726572a6757365722d37a9617574686f72697479a744656369646572a76f7574636f6d6581a85265766973696f6e81a26f66be657572656b612d73746174653a6465736b5f616e737765723a51312e6e31 resolution.superseded eureka-state:resolution:question.Q1.n1 epiphany.pipeline.resolution.v2 EpiphanyPipelineResolutionDocument 9185a77375626a65637482a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501a76f7574636f6d6581aa5375706572736564656481a262799282a46b696e64a652756c696e67a26964b6657572656b612d73746174653a72756c696e673a523882a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a9726174696f6e616c65a852756c656420412eab7265736f6c7665645f6f6eaa323032362d30392d3135 resolution.fixed-by-none eureka-state:resolution:question.Q1.n1 epiphany.pipeline.resolution.v2 EpiphanyPipelineResolutionDocument 9185a77375626a65637482a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501a76f7574636f6d6581a5466978656482a6636f6d6d6974a83566393832323864a26279c0a9726174696f6e616c65a852756c656420412eab7265736f6c7665645f6f6eaa323032362d30392d3135 resolution.fixed-by-some eureka-state:resolution:question.Q1.n1 epiphany.pipeline.resolution.v2 EpiphanyPipelineResolutionDocument 9185a77375626a65637482a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501a76f7574636f6d6581a5466978656482a6636f6d6d6974a83566393832323864a2627982a46b696e64a94375745265706f7274a26964d921657572656b612d73746174653a6375745f7265706f72743a6375742d33612e6831a9726174696f6e616c65a852756c656420412eab7265736f6c7665645f6f6eaa323032362d30392d3135 diff --git a/crates/eureka-pipeline/src/lib.rs b/crates/eureka-pipeline/src/lib.rs index 5b85395..cf4f155 100644 --- a/crates/eureka-pipeline/src/lib.rs +++ b/crates/eureka-pipeline/src/lib.rs @@ -738,6 +738,10 @@ unit_enums! { RunOperator { Operator, Mind } RunTurn { PersonaTurn, SelfRun, Sleep } MemorySort { Asked, Decided, Committed, Learned, Did, Idea, Rapport } + // What a desk answerer is, as the receiver stamped it (never a client's claim). + DeskAuthority { Operator, Decider } + // Why a desk answer was refused and stored: binding spec 1.3 steps 4-7 and R1. + DeskRefusal { StaleFork, NotADecider, ForkClosed, ChoiceInvalid, BadRetraction, AlreadyRetracted } } /// The slots of a Mind's runs: one live run of hers per instance and slot. A @@ -838,6 +842,15 @@ value_types! { structural_delta: StructuralDelta, landed_names: Vec[128], undone: Vec[32], promises: Vec[64], } + /// One answer the fork desk's receiver passed to the Mind, refused ones included. + /// `answerer` and `authority` are stamped by the Mind from the verified caller and + /// are absent from the request type; `answered_at` is the client's claim kept as + /// written, display only: order is `sequence`, the receiver's order. `sequence` is + /// per question and is the last part of the key (the answer id). + pub struct PipelineDeskAnswer { + campaign: Slug, fork: Short, sequence: u32, options_hash: Sha256Hex, choice: Option