diff --git a/crates/huginn-daemon/src/daemon.rs b/crates/huginn-daemon/src/daemon.rs index 2e3c04f..c468b39 100644 --- a/crates/huginn-daemon/src/daemon.rs +++ b/crates/huginn-daemon/src/daemon.rs @@ -110,6 +110,7 @@ pub(crate) mod tests { use huginn_mind::wire::MindStatus; use huginn_mind::{ Faculty, HistoryScope, PipelineAdmissionBatch, PipelineProvenance, PipelineQuery, PipelineStatus, + SemanticQuery, }; use std::sync::{Arc, Mutex}; use tempfile::TempDir; @@ -348,6 +349,60 @@ pub(crate) mod tests { assert_eq!(status(&mut daemon).documents, 1, "nothing landed"); } + /// A refusal a read method raised is the answer the dispatch returns, whole. + /// The instance check refuses before the method runs and is pinned above; + /// these are the refusals the methods themselves raise, so nothing here is + /// the daemon's own. Each must arrive as the mind spelled it: not rewrapped + /// as `Unavailable`, not rewrapped as another read's refusal, and not + /// swallowed into an empty answer, which would read as "no such document" + /// rather than "that is not a reference". + /// + /// `open_items` has no refusal of its own: its only failing paths are store + /// integrity inside the reader, which the daemon's surface cannot drive + /// without a corrupt store. So the daemon's `OpenItems` rewrap is not + /// pinned here and carries no mutation entry. + #[test] + fn a_refusal_a_read_raised_is_the_answer_the_dispatch_returns_whole() { + let (_root, mut daemon) = seeded(); + + // A reference whose kind and id disagree names no document any writer + // could have composed; the leaf's own refusal says so. + let invalid = PipelineRef { kind: PipelineKind::Question, id: "not a reference".into() }; + let malformed = MindRefusal::Document(huginn_mind::epiphany_pipeline::PipelineRefusal::InvalidFormat { + field: "ref.id".into(), + value: "not a reference".into(), + }); + let view = HuginnMindRequest::View { instance: slug(INSTANCE), id: invalid.clone() }; + assert_eq!(daemon.handle(view, now()), HuginnMindResponse::Refused(malformed.clone())); + + let history = HuginnMindRequest::History { instance: slug(INSTANCE), scope: HistoryScope::Subject(invalid) }; + assert_eq!(daemon.handle(history, now()), HuginnMindResponse::Refused(malformed)); + + // A semantic query is unavailable until Cut 11 wires an index, and the + // detail is the mind's own sentence. + let query = PipelineQuery { + semantic: Some(SemanticQuery { text: "who owns the state".into(), top_k: 4 }), + ..PipelineQuery::default() + }; + let unwired = + MindRefusal::Unavailable { detail: "semantic query: the index is not wired (Cut 11)".into() }; + assert_eq!( + daemon.handle(HuginnMindRequest::Query { instance: slug(INSTANCE), query }, now()), + HuginnMindResponse::Refused(unwired) + ); + + // And the same reads answer rather than refuse when they are asked + // something answerable, so the arms above are refusing on the mind's + // word and not on their own shape. + let absent = + PipelineRef { kind: PipelineKind::Question, id: Short(format!("{CAMPAIGN}:question:Q1")) }; + let present = HuginnMindRequest::View { instance: slug(INSTANCE), id: absent }; + assert_eq!(daemon.handle(present, now()), HuginnMindResponse::View(None)); + let plain = HuginnMindRequest::Query { instance: slug(INSTANCE), query: PipelineQuery::default() }; + let HuginnMindResponse::Query(page) = daemon.handle(plain, now()) else { panic!("expected a page") }; + assert_eq!(page.matched, 1); + } + /// Cut 11's seam: the index is handed every landed write, derived ones /// included, and can read each; an index that fails changes nothing. #[test] diff --git a/crates/huginn-daemon/src/serve.rs b/crates/huginn-daemon/src/serve.rs index 4d561a6..6c6ac65 100644 --- a/crates/huginn-daemon/src/serve.rs +++ b/crates/huginn-daemon/src/serve.rs @@ -288,7 +288,11 @@ mod tests { request("m-4", MIND_SERVICE_ID, "admit", MIND_REQUEST_SCHEMA, STANDARD.encode([0xc1_u8, 0xc1])), "payload-not-a-request", ), - ("m-5", request("m-5", MIND_SERVICE_ID, "query", MIND_REQUEST_SCHEMA, admit), "operation-mismatch"), + ("m-5", request("m-5", MIND_SERVICE_ID, "query", MIND_REQUEST_SCHEMA, admit.clone()), "operation-mismatch"), + // The same operation under another casing is another name, not this + // one: the comparison is of bytes, so `Admit` is no more `admit` + // than `query` is. + ("m-5b", request("m-5b", MIND_SERVICE_ID, "Admit", MIND_REQUEST_SCHEMA, admit), "operation-mismatch"), ]; for (id, message, code) in cases { let reply = answer(&mut daemon, ®istry, message, now()); diff --git a/tools/eureka-cut10-mutations.psd1 b/tools/eureka-cut10-mutations.psd1 index bd61cf6..55acba6 100644 --- a/tools/eureka-cut10-mutations.psd1 +++ b/tools/eureka-cut10-mutations.psd1 @@ -1,4 +1,4 @@ -# Cut 10 mutations, D1-D16: the CultNet surface's rules. Each rule has a +# Cut 10 mutations, D1-D19: the CultNet surface's rules. Each rule has a # revert, `Dn`, which removes the rule, and a loosening, `DnL`, which weakens # it rather than removing it, because a plain revert is the easy target. Every # entry names the test that must fail while it is applied. Run through @@ -26,6 +26,14 @@ # - `D12`'s order, weakened as far as it goes by `D12L`: a hub bound before the # mind is opened and then dropped when the mind refuses. Any weaker form is # the correct order again. +# +# One rule is deliberately not pinned and carries no entry rather than a weak +# one: the dispatch's `OpenItems` arm passes a refusal through like its three +# siblings, but `Mind::open_items` has no refusal of its own. Its only failing +# paths are store integrity inside the reader, which nothing on the daemon's +# surface can drive without a corrupt store, so a mutant that rewrapped or +# swallowed that arm's refusal could not fail any test here. `D17`-`D19` pin +# the three arms that can be driven. @{ Mutations = @( @{ @@ -229,6 +237,118 @@ Old = ' if operation != request.operation() {' New = ' if operation.len() != request.operation().len() {' } + @{ + Id = 'D17' + Rule = 'A refusal `view` raised crosses the dispatch as itself, not rewrapped as unavailable.' + Test = 'daemon::tests::a_refusal_a_read_raised_is_the_answer_the_dispatch_returns_whole' + File = 'crates/huginn-daemon/src/daemon.rs' + Old = @' + HuginnMindRequest::View { id, .. } => match self.mind.view(&id) { + Ok(view) => HuginnMindResponse::View(view), + Err(refusal) => HuginnMindResponse::Refused(refusal), + }, +'@ + New = @' + HuginnMindRequest::View { id, .. } => match self.mind.view(&id) { + Ok(view) => HuginnMindResponse::View(view), + Err(refusal) => HuginnMindResponse::Refused(huginn_mind::MindRefusal::Unavailable { + detail: format!("{refusal}"), + }), + }, +'@ + } + @{ + Id = 'D17L' + Rule = 'It is not swallowed into an empty answer either: a malformed reference is not an absent document.' + Test = 'daemon::tests::a_refusal_a_read_raised_is_the_answer_the_dispatch_returns_whole' + File = 'crates/huginn-daemon/src/daemon.rs' + Old = @' + Err(refusal) => HuginnMindResponse::Refused(refusal), + }, + HuginnMindRequest::Query { query, .. } => match self.mind.query(&query) { +'@ + New = @' + Err(_) => HuginnMindResponse::View(None), + }, + HuginnMindRequest::Query { query, .. } => match self.mind.query(&query) { +'@ + } + @{ + Id = 'D18' + Rule = 'A refusal `query` raised crosses the dispatch as itself, with the mind''s own detail.' + Test = 'daemon::tests::a_refusal_a_read_raised_is_the_answer_the_dispatch_returns_whole' + File = 'crates/huginn-daemon/src/daemon.rs' + Old = @' + HuginnMindRequest::Query { query, .. } => match self.mind.query(&query) { + Ok(page) => HuginnMindResponse::Query(page), + Err(refusal) => HuginnMindResponse::Refused(refusal), + }, +'@ + New = @' + HuginnMindRequest::Query { query, .. } => match self.mind.query(&query) { + Ok(page) => HuginnMindResponse::Query(page), + Err(refusal) => HuginnMindResponse::Refused(huginn_mind::MindRefusal::Unavailable { + detail: format!("a query was refused: {refusal}"), + }), + }, +'@ + } + @{ + Id = 'D18L' + Rule = 'Nor is it flattened into an empty page, which would read as a mind holding nothing.' + Test = 'daemon::tests::a_refusal_a_read_raised_is_the_answer_the_dispatch_returns_whole' + File = 'crates/huginn-daemon/src/daemon.rs' + Old = @' + Ok(page) => HuginnMindResponse::Query(page), + Err(refusal) => HuginnMindResponse::Refused(refusal), +'@ + New = @' + Ok(page) => HuginnMindResponse::Query(page), + Err(_) => HuginnMindResponse::Query(huginn_mind::PipelineQueryPage { items: vec![], matched: 0 }), +'@ + } + @{ + Id = 'D19' + Rule = 'A refusal `history` raised crosses the dispatch as itself, not as another read''s refusal.' + Test = 'daemon::tests::a_refusal_a_read_raised_is_the_answer_the_dispatch_returns_whole' + File = 'crates/huginn-daemon/src/daemon.rs' + Old = @' + HuginnMindRequest::History { scope, .. } => match self.mind.history(&scope) { + Ok(views) => HuginnMindResponse::History(views), + Err(refusal) => HuginnMindResponse::Refused(refusal), + }, +'@ + New = @' + HuginnMindRequest::History { scope, .. } => match self.mind.history(&scope) { + Ok(views) => HuginnMindResponse::History(views), + Err(_) => HuginnMindResponse::Refused(huginn_mind::MindRefusal::Unavailable { + detail: "semantic query: the index is not wired (Cut 11)".into(), + }), + }, +'@ + } + @{ + Id = 'D19L' + Rule = 'Nor is a history refusal swallowed into an empty history, which is the answer for a subject with no records.' + Test = 'daemon::tests::a_refusal_a_read_raised_is_the_answer_the_dispatch_returns_whole' + File = 'crates/huginn-daemon/src/daemon.rs' + Old = @' + Ok(views) => HuginnMindResponse::History(views), + Err(refusal) => HuginnMindResponse::Refused(refusal), +'@ + New = @' + Ok(views) => HuginnMindResponse::History(views), + Err(_) => HuginnMindResponse::History(vec![]), +'@ + } + @{ + Id = 'D5L2' + Rule = 'The operation is compared as bytes: another casing of the name is another name.' + Test = 'serve::tests::a_malformed_envelope_is_answered_with_a_failure_and_touches_no_mind' + File = 'crates/huginn-daemon/src/envelope.rs' + Old = ' if operation != request.operation() {' + New = ' if !operation.eq_ignore_ascii_case(request.operation()) {' + } @{ Id = 'D6' Rule = 'The index never decides an outcome: a sink that fails does not refuse a batch the mind admitted.'