Harden Epiphany promotion state replacements
This commit is contained in:
parent
8a0c305f88
commit
bb557d1484
8 changed files with 519 additions and 35 deletions
|
|
@ -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<String>,
|
||||
pub subgoals: Option<Vec<EpiphanySubgoal>>,
|
||||
pub invariants: Option<Vec<EpiphanyInvariant>>,
|
||||
pub graphs: Option<EpiphanyGraphs>,
|
||||
pub graph_frontier: Option<EpiphanyGraphFrontier>,
|
||||
pub graph_checkpoint: Option<EpiphanyGraphCheckpoint>,
|
||||
pub churn: Option<EpiphanyChurnState>,
|
||||
pub observations: Vec<EpiphanyObservation>,
|
||||
pub evidence: Vec<EpiphanyEvidenceRecord>,
|
||||
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<String>) {
|
||||
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<String>,
|
||||
) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>,
|
||||
) -> 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<String>) {
|
||||
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<String>,
|
||||
) {
|
||||
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<String>) {
|
||||
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<String>,
|
||||
) {
|
||||
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<String>) {
|
||||
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<Item = &'a str>,
|
||||
reasons: &mut Vec<String>,
|
||||
) {
|
||||
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<String>) {
|
||||
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<EpiphanyObservation>,
|
||||
evidence: Vec<EpiphanyEvidenceRecord>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Reference in a new issue