diff --git a/epiphany-core/src/promotion.rs b/epiphany-core/src/promotion.rs index 736fbff3..fc52b9cc 100644 --- a/epiphany-core/src/promotion.rs +++ b/epiphany-core/src/promotion.rs @@ -1,10 +1,24 @@ +use codex_protocol::protocol::EpiphanyChurnState; use codex_protocol::protocol::EpiphanyEvidenceRecord; +use codex_protocol::protocol::EpiphanyGraph; +use codex_protocol::protocol::EpiphanyGraphCheckpoint; +use codex_protocol::protocol::EpiphanyGraphFrontier; +use codex_protocol::protocol::EpiphanyGraphs; +use codex_protocol::protocol::EpiphanyInvariant; use codex_protocol::protocol::EpiphanyObservation; +use codex_protocol::protocol::EpiphanySubgoal; use std::collections::HashSet; #[derive(Debug, Clone)] pub struct EpiphanyPromotionInput { pub has_state_replacements: bool, + pub active_subgoal_id: Option, + pub subgoals: Option>, + pub invariants: Option>, + pub graphs: Option, + pub graph_frontier: Option, + pub graph_checkpoint: Option, + pub churn: Option, pub observations: Vec, pub evidence: Vec, pub verifier_evidence: EpiphanyEvidenceRecord, @@ -22,6 +36,19 @@ pub fn evaluate_promotion(input: EpiphanyPromotionInput) -> EpiphanyPromotionDec if !input.has_state_replacements && input.observations.is_empty() && input.evidence.is_empty() { reasons.push("promotion patch must contain at least one mutation".to_string()); } + if input.has_state_replacements { + if input.observations.is_empty() { + reasons.push( + "state replacement promotions must include at least one observation".to_string(), + ); + } + if input.evidence.is_empty() { + reasons.push( + "state replacement promotions must include at least one patch evidence record" + .to_string(), + ); + } + } validate_evidence_record(&input.verifier_evidence, "verifierEvidence", &mut reasons); if !is_accepting_status(&input.verifier_evidence.status) { @@ -47,6 +74,7 @@ pub fn evaluate_promotion(input: EpiphanyPromotionInput) -> EpiphanyPromotionDec reasons.push(format!("duplicate observation id {:?}", observation.id)); } } + validate_state_replacements(&input, &mut reasons); EpiphanyPromotionDecision { accepted: reasons.is_empty(), @@ -94,6 +122,306 @@ fn validate_observation( } } +fn validate_state_replacements(input: &EpiphanyPromotionInput, reasons: &mut Vec) { + if let Some(subgoals) = &input.subgoals { + validate_subgoals(subgoals, input.active_subgoal_id.as_deref(), reasons); + } + if let Some(active_subgoal_id) = input.active_subgoal_id.as_deref() { + require_nonempty(active_subgoal_id, "patch.active_subgoal_id", reasons); + } + if let Some(invariants) = &input.invariants { + validate_invariants(invariants, reasons); + } + if let Some(graphs) = &input.graphs { + validate_graphs(graphs, reasons); + if let Some(frontier) = &input.graph_frontier { + validate_frontier_against_graphs(frontier, graphs, reasons); + } + if let Some(checkpoint) = &input.graph_checkpoint { + validate_checkpoint_against_graphs(checkpoint, graphs, reasons); + } + } else { + if let Some(frontier) = &input.graph_frontier { + validate_frontier_shape(frontier, reasons); + } + if let Some(checkpoint) = &input.graph_checkpoint { + validate_checkpoint_shape(checkpoint, reasons); + } + } + if let Some(churn) = &input.churn { + validate_churn(churn, reasons); + } +} + +fn validate_subgoals( + subgoals: &[EpiphanySubgoal], + active_subgoal_id: Option<&str>, + reasons: &mut Vec, +) { + let mut ids = HashSet::new(); + for subgoal in subgoals { + require_nonempty(&subgoal.id, "patch.subgoals.id", reasons); + require_nonempty(&subgoal.title, "patch.subgoals.title", reasons); + require_nonempty(&subgoal.status, "patch.subgoals.status", reasons); + if !ids.insert(subgoal.id.as_str()) { + reasons.push(format!("duplicate subgoal id {:?}", subgoal.id)); + } + } + if let Some(active_subgoal_id) = active_subgoal_id + && !active_subgoal_id.trim().is_empty() + && !ids.contains(active_subgoal_id) + { + reasons.push(format!( + "active subgoal id {:?} is not present in patch.subgoals", + active_subgoal_id + )); + } +} + +fn validate_invariants(invariants: &[EpiphanyInvariant], reasons: &mut Vec) { + let mut ids = HashSet::new(); + for invariant in invariants { + require_nonempty(&invariant.id, "patch.invariants.id", reasons); + require_nonempty( + &invariant.description, + "patch.invariants.description", + reasons, + ); + require_nonempty(&invariant.status, "patch.invariants.status", reasons); + if !ids.insert(invariant.id.as_str()) { + reasons.push(format!("duplicate invariant id {:?}", invariant.id)); + } + } +} + +fn validate_graphs(graphs: &EpiphanyGraphs, reasons: &mut Vec) { + let architecture_ids = + validate_graph("patch.graphs.architecture", &graphs.architecture, reasons); + let dataflow_ids = validate_graph("patch.graphs.dataflow", &graphs.dataflow, reasons); + let mut links = HashSet::new(); + for link in &graphs.links { + require_nonempty( + &link.dataflow_node_id, + "patch.graphs.links.dataflow_node_id", + reasons, + ); + require_nonempty( + &link.architecture_node_id, + "patch.graphs.links.architecture_node_id", + reasons, + ); + if !link.dataflow_node_id.trim().is_empty() + && !dataflow_ids.contains(link.dataflow_node_id.as_str()) + { + reasons.push(format!( + "graph link references missing dataflow node {:?}", + link.dataflow_node_id + )); + } + if !link.architecture_node_id.trim().is_empty() + && !architecture_ids.contains(link.architecture_node_id.as_str()) + { + reasons.push(format!( + "graph link references missing architecture node {:?}", + link.architecture_node_id + )); + } + let key = ( + link.dataflow_node_id.as_str(), + link.architecture_node_id.as_str(), + ); + if !links.insert(key) { + reasons.push(format!( + "duplicate graph link {:?} -> {:?}", + link.dataflow_node_id, link.architecture_node_id + )); + } + } +} + +fn validate_graph<'a>( + label: &str, + graph: &'a EpiphanyGraph, + reasons: &mut Vec, +) -> HashSet<&'a str> { + let mut node_ids = HashSet::new(); + for node in &graph.nodes { + require_nonempty(&node.id, &format!("{label}.nodes.id"), reasons); + require_nonempty(&node.title, &format!("{label}.nodes.title"), reasons); + require_nonempty(&node.purpose, &format!("{label}.nodes.purpose"), reasons); + if !node_ids.insert(node.id.as_str()) { + reasons.push(format!("duplicate {label} node id {:?}", node.id)); + } + } + + let mut edge_ids = HashSet::new(); + for edge in &graph.edges { + require_nonempty( + &edge.source_id, + &format!("{label}.edges.source_id"), + reasons, + ); + require_nonempty( + &edge.target_id, + &format!("{label}.edges.target_id"), + reasons, + ); + require_nonempty(&edge.kind, &format!("{label}.edges.kind"), reasons); + if !edge.source_id.trim().is_empty() && !node_ids.contains(edge.source_id.as_str()) { + reasons.push(format!( + "{label} edge references missing source node {:?}", + edge.source_id + )); + } + if !edge.target_id.trim().is_empty() && !node_ids.contains(edge.target_id.as_str()) { + reasons.push(format!( + "{label} edge references missing target node {:?}", + edge.target_id + )); + } + if let Some(edge_id) = edge.id.as_deref() + && !edge_id.trim().is_empty() + && !edge_ids.insert(edge_id) + { + reasons.push(format!("duplicate {label} edge id {:?}", edge_id)); + } + } + + node_ids +} + +fn validate_frontier_shape(frontier: &EpiphanyGraphFrontier, reasons: &mut Vec) { + require_unique_nonempty( + "patch.graph_frontier.active_node_ids", + frontier.active_node_ids.iter().map(String::as_str), + reasons, + ); + require_unique_nonempty( + "patch.graph_frontier.active_edge_ids", + frontier.active_edge_ids.iter().map(String::as_str), + reasons, + ); + require_unique_nonempty( + "patch.graph_frontier.open_question_ids", + frontier.open_question_ids.iter().map(String::as_str), + reasons, + ); + require_unique_nonempty( + "patch.graph_frontier.open_gap_ids", + frontier.open_gap_ids.iter().map(String::as_str), + reasons, + ); +} + +fn validate_frontier_against_graphs( + frontier: &EpiphanyGraphFrontier, + graphs: &EpiphanyGraphs, + reasons: &mut Vec, +) { + validate_frontier_shape(frontier, reasons); + let node_ids = graph_node_ids(graphs); + let edge_ids = graph_edge_ids(graphs); + for node_id in &frontier.active_node_ids { + if !node_id.trim().is_empty() && !node_ids.contains(node_id.as_str()) { + reasons.push(format!( + "graph frontier references missing node {:?}", + node_id + )); + } + } + for edge_id in &frontier.active_edge_ids { + if !edge_id.trim().is_empty() && !edge_ids.contains(edge_id.as_str()) { + reasons.push(format!( + "graph frontier references missing edge {:?}", + edge_id + )); + } + } +} + +fn validate_checkpoint_shape(checkpoint: &EpiphanyGraphCheckpoint, reasons: &mut Vec) { + require_nonempty( + &checkpoint.checkpoint_id, + "patch.graph_checkpoint.checkpoint_id", + reasons, + ); + require_unique_nonempty( + "patch.graph_checkpoint.frontier_node_ids", + checkpoint.frontier_node_ids.iter().map(String::as_str), + reasons, + ); + require_unique_nonempty( + "patch.graph_checkpoint.open_question_ids", + checkpoint.open_question_ids.iter().map(String::as_str), + reasons, + ); + require_unique_nonempty( + "patch.graph_checkpoint.open_gap_ids", + checkpoint.open_gap_ids.iter().map(String::as_str), + reasons, + ); +} + +fn validate_checkpoint_against_graphs( + checkpoint: &EpiphanyGraphCheckpoint, + graphs: &EpiphanyGraphs, + reasons: &mut Vec, +) { + validate_checkpoint_shape(checkpoint, reasons); + let node_ids = graph_node_ids(graphs); + for node_id in &checkpoint.frontier_node_ids { + if !node_id.trim().is_empty() && !node_ids.contains(node_id.as_str()) { + reasons.push(format!( + "graph checkpoint references missing frontier node {:?}", + node_id + )); + } + } +} + +fn validate_churn(churn: &EpiphanyChurnState, reasons: &mut Vec) { + require_nonempty( + &churn.understanding_status, + "patch.churn.understanding_status", + reasons, + ); + require_nonempty(&churn.diff_pressure, "patch.churn.diff_pressure", reasons); +} + +fn graph_node_ids(graphs: &EpiphanyGraphs) -> HashSet<&str> { + graphs + .architecture + .nodes + .iter() + .chain(graphs.dataflow.nodes.iter()) + .map(|node| node.id.as_str()) + .collect() +} + +fn graph_edge_ids(graphs: &EpiphanyGraphs) -> HashSet<&str> { + graphs + .architecture + .edges + .iter() + .chain(graphs.dataflow.edges.iter()) + .filter_map(|edge| edge.id.as_deref()) + .collect() +} + +fn require_unique_nonempty<'a>( + label: &str, + values: impl Iterator, + reasons: &mut Vec, +) { + let mut seen = HashSet::new(); + for value in values { + require_nonempty(value, label, reasons); + if !value.trim().is_empty() && !seen.insert(value) { + reasons.push(format!("duplicate {label} {:?}", value)); + } + } +} + fn require_nonempty(value: &str, label: &str, reasons: &mut Vec) { if value.trim().is_empty() { reasons.push(format!("{label} must not be empty")); @@ -110,6 +438,8 @@ fn is_accepting_status(status: &str) -> bool { #[cfg(test)] mod tests { use super::*; + use codex_protocol::protocol::EpiphanyGraphEdge; + use codex_protocol::protocol::EpiphanyGraphNode; fn verifier(status: &str) -> EpiphanyEvidenceRecord { EpiphanyEvidenceRecord { @@ -121,26 +451,55 @@ mod tests { } } + fn evidence() -> EpiphanyEvidenceRecord { + EpiphanyEvidenceRecord { + id: "ev-1".to_string(), + kind: "verification".to_string(), + status: "ok".to_string(), + summary: "Smoke returned revision 1".to_string(), + code_refs: Vec::new(), + } + } + + fn observation() -> EpiphanyObservation { + EpiphanyObservation { + id: "obs-1".to_string(), + summary: "Smoke passed".to_string(), + source_kind: "smoke".to_string(), + status: "ok".to_string(), + code_refs: Vec::new(), + evidence_ids: vec!["ev-1".to_string()], + } + } + + fn promotion_input( + has_state_replacements: bool, + observations: Vec, + evidence: Vec, + verifier_status: &str, + ) -> EpiphanyPromotionInput { + EpiphanyPromotionInput { + has_state_replacements, + active_subgoal_id: None, + subgoals: None, + invariants: None, + graphs: None, + graph_frontier: None, + graph_checkpoint: None, + churn: None, + observations, + evidence, + verifier_evidence: verifier(verifier_status), + } + } + #[test] fn evaluate_promotion_accepts_verified_evidence_linked_observation() { let decision = evaluate_promotion(EpiphanyPromotionInput { - has_state_replacements: false, - observations: vec![EpiphanyObservation { - id: "obs-1".to_string(), - summary: "Smoke passed".to_string(), - source_kind: "smoke".to_string(), - status: "ok".to_string(), - code_refs: Vec::new(), - evidence_ids: vec!["ev-1".to_string()], - }], - evidence: vec![EpiphanyEvidenceRecord { - id: "ev-1".to_string(), - kind: "verification".to_string(), - status: "ok".to_string(), - summary: "Smoke returned revision 1".to_string(), - code_refs: Vec::new(), - }], + observations: vec![observation()], + evidence: vec![evidence()], verifier_evidence: verifier("ok"), + ..promotion_input(false, Vec::new(), Vec::new(), "ok") }); assert!(decision.accepted); @@ -149,12 +508,7 @@ mod tests { #[test] fn evaluate_promotion_rejects_non_accepting_verifier_status() { - let decision = evaluate_promotion(EpiphanyPromotionInput { - has_state_replacements: true, - observations: Vec::new(), - evidence: Vec::new(), - verifier_evidence: verifier("failed"), - }); + let decision = evaluate_promotion(promotion_input(true, Vec::new(), Vec::new(), "failed")); assert!(!decision.accepted); assert!( @@ -169,6 +523,13 @@ mod tests { fn evaluate_promotion_rejects_observation_without_existing_evidence() { let decision = evaluate_promotion(EpiphanyPromotionInput { has_state_replacements: false, + active_subgoal_id: None, + subgoals: None, + invariants: None, + graphs: None, + graph_frontier: None, + graph_checkpoint: None, + churn: None, observations: vec![EpiphanyObservation { id: "obs-1".to_string(), summary: "Smoke passed".to_string(), @@ -189,4 +550,88 @@ mod tests { .any(|reason| reason.contains("missing evidence id")) ); } + + #[test] + fn evaluate_promotion_rejects_state_replacement_without_observation_evidence() { + let decision = evaluate_promotion(EpiphanyPromotionInput { + churn: Some(EpiphanyChurnState { + understanding_status: "grounded".to_string(), + diff_pressure: "low".to_string(), + ..Default::default() + }), + ..promotion_input(true, Vec::new(), Vec::new(), "ok") + }); + + assert!(!decision.accepted); + assert!( + decision + .reasons + .iter() + .any(|reason| reason.contains("state replacement promotions")) + ); + } + + #[test] + fn evaluate_promotion_validates_graph_references_for_map_edits() { + let decision = evaluate_promotion(EpiphanyPromotionInput { + graphs: Some(EpiphanyGraphs { + architecture: EpiphanyGraph { + nodes: vec![EpiphanyGraphNode { + id: "state".to_string(), + title: "State".to_string(), + purpose: "Carry the explicit map".to_string(), + ..Default::default() + }], + edges: vec![EpiphanyGraphEdge { + source_id: "state".to_string(), + target_id: "missing".to_string(), + kind: "feeds".to_string(), + id: Some("edge-1".to_string()), + ..Default::default() + }], + }, + ..Default::default() + }), + ..promotion_input(true, vec![observation()], vec![evidence()], "ok") + }); + + assert!(!decision.accepted); + assert!( + decision + .reasons + .iter() + .any(|reason| reason.contains("missing target node")) + ); + } + + #[test] + fn evaluate_promotion_accepts_verified_map_and_churn_replacement() { + let decision = evaluate_promotion(EpiphanyPromotionInput { + graphs: Some(EpiphanyGraphs { + architecture: EpiphanyGraph { + nodes: vec![EpiphanyGraphNode { + id: "state".to_string(), + title: "State".to_string(), + purpose: "Carry the explicit map".to_string(), + ..Default::default() + }], + edges: Vec::new(), + }, + ..Default::default() + }), + graph_frontier: Some(EpiphanyGraphFrontier { + active_node_ids: vec!["state".to_string()], + ..Default::default() + }), + churn: Some(EpiphanyChurnState { + understanding_status: "grounded".to_string(), + diff_pressure: "low".to_string(), + graph_freshness: Some("fresh".to_string()), + ..Default::default() + }), + ..promotion_input(true, vec![observation()], vec![evidence()], "ok") + }); + + assert!(decision.accepted, "{:?}", decision.reasons); + } } diff --git a/notes/epiphany-current-algorithmic-map.md b/notes/epiphany-current-algorithmic-map.md index e19c0a53..c28a5754 100644 --- a/notes/epiphany-current-algorithmic-map.md +++ b/notes/epiphany-current-algorithmic-map.md @@ -699,13 +699,15 @@ The app-server handler: 7. calls `CodexThread.epiphany_update_state`. 8. returns the updated `EpiphanyThreadState`. -`evaluate_promotion` currently enforces a deliberately small policy: +`evaluate_promotion` currently enforces a deliberately small but now map-aware policy: 1. the patch must contain at least one mutation. 2. verifier evidence must have nonempty id/kind/status/summary. 3. verifier status must be accepting: `ok`, `accepted`, `verified`, `pass`, or `passed`. 4. patch evidence records must be nonempty and unique by id. 5. observations must be nonempty, unique by id, and cite existing evidence ids. +6. state replacement patches, including map/frontier/checkpoint/churn edits, must include at least one explicit observation and at least one patch evidence record. +7. subgoal, invariant, graph, frontier, checkpoint, and churn replacements get lightweight structural validation before they can reach the durable update path. Code refs: @@ -714,6 +716,7 @@ Code refs: - [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4280) - [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4308) - [epiphany-core/src/promotion.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/promotion.rs:19) +- [epiphany-core/src/promotion.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/promotion.rs:122) ### Output @@ -758,9 +761,9 @@ model/tool observations -> durable evidence/map/churn mutation ``` -That remaining missing organ is richer map/churn promotion. The typed write path exists, the first deterministic distillation proposal path exists, and the first verifier-backed promotion gate exists. What does not exist yet is a policy that turns accepted observations into graph/frontier/churn edits without drifting into automatic graph fanfic. +That remaining missing organ is richer map/churn promotion. The typed write path exists, the first deterministic distillation proposal path exists, and the first verifier-backed promotion gate exists. The first map/churn safety layer now exists too: promotion can reject state replacement patches that are not tied to explicit observations/evidence or that contain structurally broken subgoal, invariant, graph, frontier, checkpoint, or churn fields. -In natural language: current Epiphany can preserve a map, show a map, retrieve evidence for the map, draft one explicit observation/evidence patch, reject or accept a verified candidate, and apply explicit map/evidence/churn edits. It still cannot safely derive richer map/churn edits from observations by itself. Teeth are installed; chewing is still supervised. +In natural language: current Epiphany can preserve a map, show a map, retrieve evidence for the map, draft one explicit observation/evidence patch, reject or accept a verified candidate, apply explicit map/evidence/churn edits, and sanity-check those edits before they hit the ledger. It still does not derive graph edits by itself. Teeth are installed; chewing is still supervised. ## Verification Hooks @@ -771,6 +774,7 @@ Current tests cover the landed flows at useful seams: - explicit out-of-band Epiphany snapshot replay before the first user turn. - deterministic observation/evidence distillation in `epiphany-core`. - verifier-backed promotion policy in `epiphany-core`. +- map/churn promotion validation for evidence-backed state replacements in `epiphany-core`. - typed state-update patch application in `codex-core`. - retrieval ranking, fallback, stale manifest detection, and mocked Qdrant/Ollama indexing in `epiphany-core`. - app-server protocol serde for `thread/epiphany/retrieve`, `thread/epiphany/index`, `thread/epiphany/distill`, `thread/epiphany/promote`, and `thread/epiphany/update`. diff --git a/notes/epiphany-fork-implementation-plan.md b/notes/epiphany-fork-implementation-plan.md index f5af437b..dca62ae4 100644 --- a/notes/epiphany-fork-implementation-plan.md +++ b/notes/epiphany-fork-implementation-plan.md @@ -14,8 +14,9 @@ This note tracks Epiphany as a fork of Codex with an opinionated modeling archit - the heavy Epiphany-owned prompt/replay/retrieval implementation has now been extracted into the repo-owned `epiphany-core` crate, leaving vendored Codex with thin host adapters plus the typed integration seam - a first typed state-update slice that adds explicit loaded-thread-only `thread/epiphany/update`, appends observations/evidence, replaces bounded map/scratch/churn fields, bumps the state revision, and persists an immediate rollout `EpiphanyState` snapshot - a first typed distillation/proposal slice that adds read-only loaded-thread-only `thread/epiphany/distill`, producing deterministic observation/evidence patches for explicit promotion through `thread/epiphany/update` +- a first richer promotion-policy slice that treats map/churn/frontier/checkpoint replacements as higher-risk state edits: accepted promotions must be tied to explicit observations and patch evidence, and structural graph/churn mistakes are rejected before the durable update path -The next job is no longer to prove the retriever exists, sketch the persistent follow-up in prose, invent the first red-pen path, or build the first observation proposal surface. Those parts are landed. The next job is to keep the landing zone honest: do not casually turn `thread/epiphany/retrieve` or `thread/epiphany/distill` into durable Epiphany-state writers, and do not widen the explicit indexing/update paths into watcher-driven or GUI-shaped machinery before they earn it. +The next job is no longer to prove the retriever exists, sketch the persistent follow-up in prose, invent the first red-pen path, build the first observation proposal surface, or make promotion notice broken map/churn replacements. Those parts are landed. The next job is to keep the landing zone honest: do not casually turn `thread/epiphany/retrieve` or `thread/epiphany/distill` into durable Epiphany-state writers, and do not widen the explicit indexing/update paths into watcher-driven or GUI-shaped machinery before they earn it. ## Summary @@ -227,6 +228,19 @@ Current bounded shape of that follow-up: - it returns deterministic observation/evidence records inside a `ThreadEpiphanyUpdatePatch` - callers must pass the patch to `thread/epiphany/update` to make it durable +Follow-up addition now landed after the promotion gate: + +- richer `thread/epiphany/promote` policy for state replacement patches + +Current bounded shape of that follow-up: + +- it keeps the wire protocol stable +- it still rejects failed verifier evidence without mutation +- it now passes replacement fields into `epiphany-core` policy evaluation instead of reducing them to a generic boolean +- map/churn/frontier/checkpoint replacements must include explicit observations and patch evidence, so state edits are tied to a verified observation trail +- subgoals, invariants, graphs, graph links, frontier ids, checkpoint ids, and churn fields get lightweight structural validation before accepted patches are sent to `thread/epiphany/update` +- it still does not infer graph edits automatically; callers must submit explicit typed replacements + ### 5. Reuse existing exact-search substrate where practical Codex already has fuzzy/exact-ish repo search substrate. Reuse what is useful instead of building a second exact-search toy from scratch. @@ -258,9 +272,9 @@ Treat the current retrieval baseline, explicit indexing follow-up, explicit upda 2. treat the verified and live-smoked `thread/epiphany/index` slice as the bounded persistent-semantic follow-up 3. keep the new `epiphany-core` boundary honest instead of letting vendored Codex re-accumulate the heavy implementation 4. do not add durable retrieval-summary writes from `thread/epiphany/retrieve` without a clean out-of-band rollout/update semantic -5. build the next layer above the landed distill/update surfaces: +5. build the next layer above the landed distill/update/promote surfaces: - richer typed observation distillation from tool/model outputs - - promotion policy that decides when to call `thread/epiphany/update` explicitly + - promotion policy beyond structural validation, but only when it can stay evidence-backed - verifier-backed acceptance/rejection evidence - no hidden retrieval writes - not GUI work diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 1856069e..4cac81a8 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -190,6 +190,7 @@ What is wired right now: - app-server message handling now routes those methods through the core host seam - `thread/epiphany/distill` is the first read-only proposal surface; it returns an `expectedRevision` plus a patch and requires explicit promotion through `thread/epiphany/update` - `thread/epiphany/promote` is the first explicit promotion gate; it rejects failed verifier evidence without mutation and applies accepted candidates through the update path +- `thread/epiphany/promote` now has a first richer map/churn safety layer: state replacement patches must carry explicit observations and patch evidence, and subgoal/invariant/graph/frontier/checkpoint/churn structure is validated before the durable update path is called - `thread/epiphany/update` is the first explicit durable Epiphany state write surface; it rejects empty patches, supports optional revision matching, appends observations/evidence, replaces bounded typed fields, increments state revision, and persists immediately - live hydrated `thread.epiphanyState` reads now backfill a lightweight retrieval summary when the thread already has Epiphany state but no persisted retrieval summary yet - basic protocol/core/app-server unit tests were added for the new types and mapping layer @@ -429,7 +430,7 @@ Without that, you get to learn about `symlink_dir failed: ... A required privile Do not restart verification from superstition. The current Phase 4 retrieval/indexing/core-extraction baseline is already verified, committed, and pushed. -The durable state seam exists, the turn loop reads it, clients can load typed Epiphany state directly, the first retrieval slice is real, the explicit persistent-semantic indexing path is landed, and the first distill/promote/update path is live-smoked. The next clean move is richer map/churn promotion policy, not pretending the already-shipped slices still need ceremony. +The durable state seam exists, the turn loop reads it, clients can load typed Epiphany state directly, the first retrieval slice is real, the explicit persistent-semantic indexing path is landed, the first distill/promote/update path is live-smoked, and promotion now has a first structural map/churn safety layer. The next clean move is richer observation-to-map proposal machinery, not pretending the already-shipped slices still need ceremony. Current next implementation move: @@ -438,7 +439,7 @@ Current next implementation move: 3. keep `thread/epiphany/retrieve` and `thread/epiphany/distill` read-only 4. build the next layer above the distill/promote/update surfaces: - richer map/churn patch generation from verified observations - - promotion policy beyond simple evidence-linked observation acceptance + - promotion policy beyond structural validation only when the evidence path stays explicit - verifier-backed acceptance/rejection evidence - no GUI, watcher, or specialist-agent machinery yet diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 392bef89..79a5add3 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -89,3 +89,5 @@ {"ts": "2026-04-24T17:27:49+00:00", "type": "verification", "status": "ok", "note": "Live app-server stdio smoke verified thread/epiphany/promote. Distilled a promotion smoke patch in ephemeral loaded thread 019dc087-6d3d-7323-9a2f-2487a591ef7a, rejected failed verifier evidence with no state mutation, then accepted ok verifier evidence, promoted through the durable update path to revision 1, and thread/read returned observation obs-77248505f492 plus evidence ev-77248505f492 and ev-promote-smoke-verifier. Smoke result is .epiphany-smoke/promote-smoke-result.json."} {"ts": "2026-04-24T17:49:26+00:00", "type": "implementation", "status": "ok", "note": "Updated notes/epiphany-current-algorithmic-map.md for the landed thread/epiphany/promote flow. The current model now includes promote as a verifier-backed gate between distill proposals and the durable update path, with source-grounded refs to app-server protocol DTOs, app-server routing/handler code, and epiphany-core promotion policy evaluation."} {"ts": "2026-04-24T17:52:09+00:00", "type": "decision", "status": "ok", "note": "Documented compact-rehydrate-continue as an explicit Epiphany architecture primitive. The cycle is: compact from a checkpoint when possible, rehydrate from canonical map/handoff/evidence rather than prompt residue, restate the persisted next action, and continue with one bounded move."} +{"ts": "2026-04-24T18:05:25+00:00", "type": "implementation", "status": "ok", "note": "Landed first richer promotion safety layer: state replacement promotions now require explicit observation/evidence records and validate subgoal/invariant/graph/frontier/checkpoint/churn structure in epiphany-core before app-server forwards accepted patches to the durable update path."} +{"ts": "2026-04-24T18:06:30+00:00", "type": "verification", "status": "ok", "note": "Verified richer promotion safety layer with epiphany-core full tests, codex-core epiphany tests, and codex-app-server epiphany tests. Full codex-app-server --lib run compiled but hit an unrelated stack overflow in in_process_start_uses_requested_session_source_for_thread_start; Epiphany-scoped app-server tests passed."} diff --git a/state/map.yaml b/state/map.yaml index 0b859970..606a19ca 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -49,7 +49,7 @@ active_subgoals: - Keep the extracted Phase 4 persistent-semantic follow-up aligned with reality in both code and repo memory. - Keep vendored Codex as a thin typed host seam while Epiphany-owned prompt and retrieval/indexing logic live in the repo-owned `epiphany-core` crate. - Preserve exact/path lookup and BM25 fallback while the Qdrant path stays explicit and bounded. - - Build the next layer above the landed typed distill/promote/update paths: richer map/churn promotion and verifier-backed acceptance/rejection beyond simple observation patches. + - Build the next layer above the landed typed distill/promote/update paths: richer observation-to-map proposal machinery while keeping verifier-backed acceptance/rejection explicit. accepted_design: - external typed state files @@ -93,6 +93,7 @@ accepted_design: - land Epiphany's first explicit typed state-update surface as `thread/epiphany/update`: loaded-thread-only, revision-checked when requested, append observations/evidence, replace bounded map/scratch/churn fields, persist an immediate `RolloutItem::EpiphanyState`, and keep retrieval read-only - land Epiphany's first typed distillation/proposal surface as `thread/epiphany/distill`: loaded-thread-only, read-only, deterministic observation/evidence patch generation with a concrete `expectedRevision`, and explicit promotion through `thread/epiphany/update` - land Epiphany's first explicit promotion gate as `thread/epiphany/promote`: loaded-thread-only, verifier-backed, rejects failed candidates without mutation, and applies accepted candidates through the existing durable update path + - land the first richer map/churn promotion safety layer: state replacement promotions must include explicit observations and patch evidence, and subgoal/invariant/graph/frontier/checkpoint/churn replacements get structural validation in `epiphany-core` before reaching the durable update path rejected_paths: - name: pinned_map_only @@ -109,5 +110,5 @@ rejected_paths: reason: audited candidates were either terminal-only, stale/aspirational, or incoherent wrappers; the comparison clones were removed from vendor to avoid building on untrusted drift current_status: - summary: Workspace initialized with protocol files, typed state, a tiny CLI, a git repo, fresh-workspace handoff notes, architecture rationale, a Codex algorithmic map note, an Epiphany core-harness surface spec, and a rolling implementation plan against vendored Codex. Phase 1 durable Epiphany state, Phase 2 prompt integration, a minimal Phase 3 typed client read surface, and the Phase 4 retrieval/indexing/core-extraction work are landed on `main` at `80c29e0`. The current working tree adds the first explicit typed state-update surface, first read-only typed distillation/proposal surface, and first explicit verifier-backed promotion gate: experimental loaded-thread-only `thread/epiphany/update`, `thread/epiphany/distill`, and `thread/epiphany/promote`; typed patch application through `CodexThread.epiphany_update_state(...)`; deterministic observation/evidence proposal generation and promotion policy evaluation in `epiphany-core`; immediate rollout persistence only on accepted promotion/update; and replay support for pre-turn out-of-band Epiphany snapshots. Live app-server stdio smokes verified update, distill-to-update, and promote policy end-to-end: failed verifier evidence is rejected without state mutation, accepted verifier evidence promotes through the durable update path, and thread/read returns revision 1 with the promoted observation/evidence. The current baseline includes explicit `thread/epiphany/index`, manifest-backed Qdrant semantic persistence under `codex_home`, local Ollama embeddings by default, stale/fresh retrieval metadata, and read-only retrieval that still preserves exact/path search plus BM25 fallback instead of turning the machine into vector-only soup. A live app-server smoke on 2026-04-24 verified the explicit indexing path against real local Qdrant/Ollama using an ephemeral loaded thread rooted at `epiphany-core`: 5 files indexed into 198 chunks, a Qdrant collection was created, and persistent semantic retrieval returned `src/prompt.rs` chunks from the fresh index. Env-scoped backend config is good enough for now. The heavy Epiphany-owned prompt renderer, rollout replay helper, retrieval/indexing engine, observation distiller, and promotion evaluator live in the repo-owned `epiphany-core` crate, while vendored Codex carries thin adapters and the typed host seam. The repo also now has an operative source-available/commercial licensing notice and a CLA gate for external contributions. - next_action: Build the next bounded layer above `thread/epiphany/distill`, `thread/epiphany/promote`, and `thread/epiphany/update`: richer promotion of map/churn edits from verified observations, while keeping retrieval and distillation read-only and avoiding watcher, GUI, or specialist-agent machinery. + summary: Workspace initialized with protocol files, typed state, a tiny CLI, a git repo, fresh-workspace handoff notes, architecture rationale, a Codex algorithmic map note, an Epiphany core-harness surface spec, and a rolling implementation plan against vendored Codex. Phase 1 durable Epiphany state, Phase 2 prompt integration, a minimal Phase 3 typed client read surface, and the Phase 4 retrieval/indexing/core-extraction work are landed on `main` at `80c29e0`. The current working tree adds the first explicit typed state-update surface, first read-only typed distillation/proposal surface, first explicit verifier-backed promotion gate, and first richer map/churn promotion safety layer: experimental loaded-thread-only `thread/epiphany/update`, `thread/epiphany/distill`, and `thread/epiphany/promote`; typed patch application through `CodexThread.epiphany_update_state(...)`; deterministic observation/evidence proposal generation; promotion policy evaluation in `epiphany-core`; explicit observation/evidence requirements for state replacement promotions; structural validation for subgoal, invariant, graph, frontier, checkpoint, and churn replacements; immediate rollout persistence only on accepted promotion/update; and replay support for pre-turn out-of-band Epiphany snapshots. Live app-server stdio smokes verified update, distill-to-update, and promote policy end-to-end before the richer structural layer: failed verifier evidence is rejected without state mutation, accepted verifier evidence promotes through the durable update path, and thread/read returns revision 1 with the promoted observation/evidence. The current baseline includes explicit `thread/epiphany/index`, manifest-backed Qdrant semantic persistence under `codex_home`, local Ollama embeddings by default, stale/fresh retrieval metadata, and read-only retrieval that still preserves exact/path search plus BM25 fallback instead of turning the machine into vector-only soup. A live app-server smoke on 2026-04-24 verified the explicit indexing path against real local Qdrant/Ollama using an ephemeral loaded thread rooted at `epiphany-core`: 5 files indexed into 198 chunks, a Qdrant collection was created, and persistent semantic retrieval returned `src/prompt.rs` chunks from the fresh index. Env-scoped backend config is good enough for now. The heavy Epiphany-owned prompt renderer, rollout replay helper, retrieval/indexing engine, observation distiller, and promotion evaluator live in the repo-owned `epiphany-core` crate, while vendored Codex carries thin adapters and the typed host seam. The repo also now has an operative source-available/commercial licensing notice and a CLA gate for external contributions. + next_action: Build the next bounded layer above `thread/epiphany/distill`, `thread/epiphany/promote`, and `thread/epiphany/update`: richer observation-to-map proposal machinery from verified observations, while keeping retrieval and distillation read-only and avoiding watcher, GUI, or specialist-agent machinery. diff --git a/state/scratch.md b/state/scratch.md index 250ec1f6..6182e879 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -4,7 +4,7 @@ This file is intentionally disposable. ## Current Subgoal -- Move from the landed explicit typed state-update, distillation, and promotion surfaces toward richer verified map/churn promotion without hidden writes. +- Move from structural map/churn promotion validation toward richer observation-to-map proposal machinery without hidden writes. ## Working Notes @@ -126,10 +126,20 @@ This file is intentionally disposable. - accepted verifier evidence promoted through update to revision `1` - `thread/read` returned observation `obs-77248505f492`, distill evidence `ev-77248505f492`, and verifier evidence `ev-promote-smoke-verifier` - smoke result: `.epiphany-smoke/promote-smoke-result.json` +- Richer promotion safety layer on 2026-04-24: + - `epiphany-core/src/promotion.rs` now receives the actual replacement fields instead of only a `has_state_replacements` boolean + - state replacement promotions must include explicit observations and patch evidence before verifier evidence can stamp them into the durable update path + - subgoal, invariant, graph, graph link, frontier, checkpoint, and churn replacements get lightweight structural validation + - `vendor/codex/codex-rs/app-server/src/codex_message_processor.rs` still keeps the host seam thin: it passes the patch fields into `evaluate_promotion`, then accepted promotions go through `CodexThread.epiphany_update_state(...)` + - verification passed: + - `cargo test --manifest-path .\\epiphany-core\\Cargo.toml` + - `cargo test -p codex-core --lib epiphany` + - `cargo test -p codex-app-server --lib epiphany` + - attempted full `cargo test -p codex-app-server --lib` compiled but hit a pre-existing-looking stack overflow in `in_process_start_uses_requested_session_source_for_thread_start`; Epiphany-scoped app-server tests passed ## Open Questions - How much more can move into `epiphany-core` without sacrificing the typed Codex host seam that makes the integration first-class? -- How should verified observations imply map/churn edits without turning promotion into automatic graph fanfic? +- How should verified observations propose map/churn edits without turning promotion into automatic graph fanfic? Do not promote anything from here into the map unless it survives verification or repeated reuse without contradiction. diff --git a/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs b/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs index aaf28851..4336238e 100644 --- a/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs +++ b/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs @@ -4269,6 +4269,13 @@ impl CodexMessageProcessor { let decision = evaluate_promotion(EpiphanyPromotionInput { has_state_replacements: thread_epiphany_patch_has_state_replacements(&patch), + active_subgoal_id: patch.active_subgoal_id.clone(), + subgoals: patch.subgoals.clone(), + invariants: patch.invariants.clone(), + graphs: patch.graphs.clone(), + graph_frontier: patch.graph_frontier.clone(), + graph_checkpoint: patch.graph_checkpoint.clone(), + churn: patch.churn.clone(), observations: patch.observations.clone(), evidence: patch.evidence.clone(), verifier_evidence: verifier_evidence.clone(),