diff --git a/apps/elf-eval/src/bin/agentmemory_fixture_adapter.rs b/apps/elf-eval/src/bin/agentmemory_fixture_adapter/main.rs similarity index 69% rename from apps/elf-eval/src/bin/agentmemory_fixture_adapter.rs rename to apps/elf-eval/src/bin/agentmemory_fixture_adapter/main.rs index 5d8c267a..28a2a919 100644 --- a/apps/elf-eval/src/bin/agentmemory_fixture_adapter.rs +++ b/apps/elf-eval/src/bin/agentmemory_fixture_adapter/main.rs @@ -2,12 +2,12 @@ //! Offline adapter for agentmemory-style fixture exports. -#[path = "agentmemory_fixture_adapter/adapt.rs"] mod adapt; -#[path = "agentmemory_fixture_adapter/cli.rs"] mod cli; -#[path = "agentmemory_fixture_adapter/io.rs"] mod io; -#[path = "agentmemory_fixture_adapter/mapping.rs"] mod mapping; -#[path = "agentmemory_fixture_adapter/types.rs"] mod types; -#[path = "agentmemory_fixture_adapter/util.rs"] mod util; +mod adapt; +mod cli; +mod io; +mod mapping; +mod types; +mod util; use clap::Parser; use color_eyre::Result; diff --git a/apps/elf-eval/src/bin/external_memory_pattern_radar.rs b/apps/elf-eval/src/bin/external_memory_pattern_radar/main.rs similarity index 59% rename from apps/elf-eval/src/bin/external_memory_pattern_radar.rs rename to apps/elf-eval/src/bin/external_memory_pattern_radar/main.rs index 46fe4d62..ed7b6249 100644 --- a/apps/elf-eval/src/bin/external_memory_pattern_radar.rs +++ b/apps/elf-eval/src/bin/external_memory_pattern_radar/main.rs @@ -2,14 +2,14 @@ //! Weekly external memory pattern radar runner. -#[path = "external_memory_pattern_radar/cli.rs"] mod cli; -#[path = "external_memory_pattern_radar/decision.rs"] mod decision; -#[path = "external_memory_pattern_radar/github.rs"] mod github; -#[path = "external_memory_pattern_radar/io.rs"] mod io; -#[path = "external_memory_pattern_radar/render.rs"] mod render; -#[path = "external_memory_pattern_radar/runtime.rs"] mod runtime; -#[path = "external_memory_pattern_radar/types.rs"] mod types; -#[path = "external_memory_pattern_radar/validation.rs"] mod validation; +mod cli; +mod decision; +mod github; +mod io; +mod render; +mod runtime; +mod types; +mod validation; use clap::Parser; use color_eyre::Result; diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/temporal.rs b/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/temporal.rs index d94e13ca..36371dc8 100644 --- a/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/temporal.rs +++ b/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/temporal.rs @@ -1,10 +1,11 @@ -use crate::{ - Value, - evidence_selection::{ - self, BTreeSet, CorpusText, IngestedCorpus, LiveExpectedClaim, LiveMemoryEvolution, - LoadedJob, SelectedEvidenceText, TemporalReconciliationMaterializationEvidence, - TemporalReconciliationSelection, TraceStageOutput, common, - }, +mod content; +mod evidence; +mod ids; +mod trace; + +use crate::evidence_selection::{ + self, BTreeSet, CorpusText, IngestedCorpus, LoadedJob, SelectedEvidenceText, + TemporalReconciliationSelection, }; pub(super) fn temporal_reconciliation_selection_impl( @@ -14,7 +15,7 @@ pub(super) fn temporal_reconciliation_selection_impl( ingested: &IngestedCorpus, ) -> Option { let evolution = loaded.job.memory_evolution.as_ref()?; - let relevant_ids = temporal_reconciliation_relevant_ids(loaded, evolution); + let relevant_ids = ids::temporal_reconciliation_relevant_ids(loaded, evolution); let retrieved_ids = retrieved_evidence_ids.iter().map(String::as_str).collect::>(); let mut selected_ids = Vec::new(); @@ -30,9 +31,9 @@ pub(super) fn temporal_reconciliation_selection_impl( return None; } - let content = temporal_reconciliation_content(loaded, corpus, &selected_ids); + let content = content::temporal_reconciliation_content(loaded, corpus, &selected_ids); let selected = SelectedEvidenceText { content, evidence_ids: selected_ids.clone() }; - let evidence = temporal_reconciliation_evidence( + let evidence = evidence::temporal_reconciliation_evidence( evolution, &relevant_ids, retrieved_evidence_ids, @@ -41,267 +42,7 @@ pub(super) fn temporal_reconciliation_selection_impl( loaded, ); let trace_stages = - temporal_reconciliation_trace_stages(evolution, retrieved_evidence_ids, &evidence); + trace::temporal_reconciliation_trace_stages(evolution, retrieved_evidence_ids, &evidence); Some(TemporalReconciliationSelection { selected, evidence, trace_stages }) } - -fn temporal_reconciliation_relevant_ids( - loaded: &LoadedJob, - evolution: &LiveMemoryEvolution, -) -> Vec { - let mut ids = Vec::new(); - - for evidence in &loaded.job.required_evidence { - evidence_selection::push_unique(&mut ids, evidence.evidence_id.clone()); - } - for evidence_id in &evolution.current_evidence_ids { - evidence_selection::push_unique(&mut ids, evidence_id.clone()); - } - for evidence_id in &evolution.historical_evidence_ids { - evidence_selection::push_unique(&mut ids, evidence_id.clone()); - } - for evidence_id in &evolution.tombstone_evidence_ids { - evidence_selection::push_unique(&mut ids, evidence_id.clone()); - } - for evidence_id in &evolution.invalidation_evidence_ids { - evidence_selection::push_unique(&mut ids, evidence_id.clone()); - } - for conflict in &evolution.conflicts { - evidence_selection::push_unique(&mut ids, conflict.current_evidence_id.clone()); - evidence_selection::push_unique(&mut ids, conflict.historical_evidence_id.clone()); - - if let Some(evidence_id) = &conflict.resolved_by_evidence_id { - evidence_selection::push_unique(&mut ids, evidence_id.clone()); - } - } - - if let Some(rationale) = &evolution.update_rationale - && rationale.available - { - for evidence_id in &rationale.evidence_ids { - evidence_selection::push_unique(&mut ids, evidence_id.clone()); - } - } - - ids -} - -fn temporal_reconciliation_content( - loaded: &LoadedJob, - corpus: &[CorpusText], - selected_ids: &[String], -) -> String { - let expected = loaded - .job - .expected_answer - .must_include - .iter() - .map(LiveExpectedClaim::text) - .collect::>() - .join(" "); - let evidence_summary = selected_ids - .iter() - .filter_map(|evidence_id| { - corpus - .iter() - .find(|item| item.evidence_id == *evidence_id) - .map(|item| format!("{evidence_id}: {}", item.text)) - }) - .collect::>() - .join("\n"); - - if evidence_summary.is_empty() { - expected - } else { - format!("{expected}\n\nTemporal reconciliation evidence:\n{evidence_summary}") - } -} - -fn temporal_reconciliation_evidence( - evolution: &LiveMemoryEvolution, - relevant_ids: &[String], - retrieved_evidence_ids: &[String], - selected_ids: &[String], - ingested: &IngestedCorpus, - loaded: &LoadedJob, -) -> TemporalReconciliationMaterializationEvidence { - let selected = selected_ids.iter().map(String::as_str).collect::>(); - let retrieved = retrieved_evidence_ids.iter().map(String::as_str).collect::>(); - let mut evidence = TemporalReconciliationMaterializationEvidence { - current_winner_evidence_ids: selected_subset(&evolution.current_evidence_ids, &selected), - historical_loser_evidence_ids: selected_subset( - &evolution.historical_evidence_ids, - &selected, - ), - supersession_rationale_evidence_ids: evolution - .update_rationale - .as_ref() - .filter(|rationale| rationale.available) - .map_or_else(Vec::new, |rationale| selected_subset(&rationale.evidence_ids, &selected)), - tombstone_evidence_ids: selected_subset(&evolution.tombstone_evidence_ids, &selected), - invalidation_evidence_ids: selected_subset(&evolution.invalidation_evidence_ids, &selected), - conflict_candidate_evidence_ids: conflict_candidate_ids(evolution, &selected), - retrieved_evidence_ids: retrieved_evidence_ids.to_vec(), - selected_evidence_ids: selected_ids.to_vec(), - absent_evidence_ids: relevant_ids - .iter() - .filter(|id| !ingested.note_ids_by_evidence.contains_key(*id)) - .cloned() - .collect(), - retrieved_but_dropped_evidence_ids: relevant_ids - .iter() - .filter(|id| retrieved.contains(id.as_str()) && !selected.contains(id.as_str())) - .cloned() - .collect(), - selected_but_not_narrated_evidence_ids: selected_but_not_narrated_ids(loaded, selected_ids), - contradicted_by_lifecycle_evidence_ids: Vec::new(), - }; - - for evidence_id in evidence - .historical_loser_evidence_ids - .iter() - .chain(evidence.tombstone_evidence_ids.iter()) - .chain(evidence.invalidation_evidence_ids.iter()) - { - evidence_selection::push_unique( - &mut evidence.contradicted_by_lifecycle_evidence_ids, - evidence_id.clone(), - ); - } - - evidence -} - -fn selected_subset(ids: &[String], selected: &BTreeSet<&str>) -> Vec { - ids.iter().filter(|id| selected.contains(id.as_str())).cloned().collect() -} - -fn conflict_candidate_ids( - evolution: &LiveMemoryEvolution, - selected: &BTreeSet<&str>, -) -> Vec { - let mut ids = Vec::new(); - - for conflict in &evolution.conflicts { - common::push_if_selected(&mut ids, conflict.current_evidence_id.as_str(), selected); - common::push_if_selected(&mut ids, conflict.historical_evidence_id.as_str(), selected); - - if let Some(evidence_id) = &conflict.resolved_by_evidence_id { - common::push_if_selected(&mut ids, evidence_id.as_str(), selected); - } - } - - ids -} - -fn selected_but_not_narrated_ids(loaded: &LoadedJob, selected_ids: &[String]) -> Vec { - let claims = evidence_selection::temporal_reconciliation_claims(loaded, selected_ids); - let narrated = claims - .iter() - .flat_map(|claim| { - claim - .get("evidence_ids") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_str) - }) - .collect::>(); - - selected_ids.iter().filter(|id| !narrated.contains(id.as_str())).cloned().collect() -} - -fn temporal_reconciliation_trace_stages( - evolution: &LiveMemoryEvolution, - retrieved_evidence_ids: &[String], - evidence: &TemporalReconciliationMaterializationEvidence, -) -> Vec { - let selected = - evidence.selected_evidence_ids.iter().map(String::as_str).collect::>(); - let retrieved = retrieved_evidence_ids.iter().map(String::as_str).collect::>(); - let expected_not_retrieved = evidence - .selected_evidence_ids - .iter() - .filter(|id| !retrieved.contains(id.as_str())) - .cloned() - .collect::>(); - - vec![ - TraceStageOutput { - stage_name: "live_adapter.retrieve".to_string(), - kept_evidence: retrieved_evidence_ids.to_vec(), - dropped_evidence: expected_not_retrieved, - demoted_evidence: Vec::new(), - distractor_evidence: evidence.absent_evidence_ids.clone(), - notes: - "Search output is compared with the temporal reconciliation evidence contract." - .to_string(), - }, - TraceStageOutput { - stage_name: "temporal_reconciliation.current_winner".to_string(), - kept_evidence: evidence.current_winner_evidence_ids.clone(), - dropped_evidence: unselected_subset(&evolution.current_evidence_ids, &selected), - demoted_evidence: Vec::new(), - distractor_evidence: Vec::new(), - notes: "Current evidence selected as the answer winner.".to_string(), - }, - TraceStageOutput { - stage_name: "temporal_reconciliation.historical_loser".to_string(), - kept_evidence: evidence.historical_loser_evidence_ids.clone(), - dropped_evidence: unselected_subset(&evolution.historical_evidence_ids, &selected), - demoted_evidence: evidence.historical_loser_evidence_ids.clone(), - distractor_evidence: Vec::new(), - notes: "Historical evidence preserved as history, not as the current answer." - .to_string(), - }, - TraceStageOutput { - stage_name: "temporal_reconciliation.supersession_rationale".to_string(), - kept_evidence: evidence.supersession_rationale_evidence_ids.clone(), - dropped_evidence: evolution - .update_rationale - .as_ref() - .map_or_else(Vec::new, |rationale| { - unselected_subset(&rationale.evidence_ids, &selected) - }), - demoted_evidence: Vec::new(), - distractor_evidence: Vec::new(), - notes: "Rationale evidence selected to explain why the older fact was superseded." - .to_string(), - }, - TraceStageOutput { - stage_name: "temporal_reconciliation.tombstone_invalidation".to_string(), - kept_evidence: evidence - .tombstone_evidence_ids - .iter() - .chain(evidence.invalidation_evidence_ids.iter()) - .cloned() - .collect(), - dropped_evidence: evolution - .tombstone_evidence_ids - .iter() - .chain(evolution.invalidation_evidence_ids.iter()) - .filter(|id| !selected.contains(id.as_str())) - .cloned() - .collect(), - demoted_evidence: Vec::new(), - distractor_evidence: Vec::new(), - notes: "Tombstone or TTL invalidation evidence remains answerable when present." - .to_string(), - }, - TraceStageOutput { - stage_name: "temporal_reconciliation.conflict_candidates".to_string(), - kept_evidence: evidence.conflict_candidate_evidence_ids.clone(), - dropped_evidence: evidence.retrieved_but_dropped_evidence_ids.clone(), - demoted_evidence: evidence.contradicted_by_lifecycle_evidence_ids.clone(), - distractor_evidence: evidence.selected_but_not_narrated_evidence_ids.clone(), - notes: - "Conflict candidates record selected, dropped, non-narrated, and lifecycle-demoted evidence." - .to_string(), - }, - ] -} - -fn unselected_subset(ids: &[String], selected: &BTreeSet<&str>) -> Vec { - ids.iter().filter(|id| !selected.contains(id.as_str())).cloned().collect() -} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/temporal/content.rs b/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/temporal/content.rs new file mode 100644 index 00000000..bd81bad6 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/temporal/content.rs @@ -0,0 +1,32 @@ +use crate::evidence_selection::{CorpusText, LiveExpectedClaim, LoadedJob}; + +pub(super) fn temporal_reconciliation_content( + loaded: &LoadedJob, + corpus: &[CorpusText], + selected_ids: &[String], +) -> String { + let expected = loaded + .job + .expected_answer + .must_include + .iter() + .map(LiveExpectedClaim::text) + .collect::>() + .join(" "); + let evidence_summary = selected_ids + .iter() + .filter_map(|evidence_id| { + corpus + .iter() + .find(|item| item.evidence_id == *evidence_id) + .map(|item| format!("{evidence_id}: {}", item.text)) + }) + .collect::>() + .join("\n"); + + if evidence_summary.is_empty() { + expected + } else { + format!("{expected}\n\nTemporal reconciliation evidence:\n{evidence_summary}") + } +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/temporal/evidence.rs b/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/temporal/evidence.rs new file mode 100644 index 00000000..4b180998 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/temporal/evidence.rs @@ -0,0 +1,101 @@ +use crate::{ + Value, + evidence_selection::{ + self, BTreeSet, IngestedCorpus, LiveMemoryEvolution, LoadedJob, + TemporalReconciliationMaterializationEvidence, common, + }, +}; + +pub(super) fn temporal_reconciliation_evidence( + evolution: &LiveMemoryEvolution, + relevant_ids: &[String], + retrieved_evidence_ids: &[String], + selected_ids: &[String], + ingested: &IngestedCorpus, + loaded: &LoadedJob, +) -> TemporalReconciliationMaterializationEvidence { + let selected = selected_ids.iter().map(String::as_str).collect::>(); + let retrieved = retrieved_evidence_ids.iter().map(String::as_str).collect::>(); + let mut evidence = TemporalReconciliationMaterializationEvidence { + current_winner_evidence_ids: selected_subset(&evolution.current_evidence_ids, &selected), + historical_loser_evidence_ids: selected_subset( + &evolution.historical_evidence_ids, + &selected, + ), + supersession_rationale_evidence_ids: evolution + .update_rationale + .as_ref() + .filter(|rationale| rationale.available) + .map_or_else(Vec::new, |rationale| selected_subset(&rationale.evidence_ids, &selected)), + tombstone_evidence_ids: selected_subset(&evolution.tombstone_evidence_ids, &selected), + invalidation_evidence_ids: selected_subset(&evolution.invalidation_evidence_ids, &selected), + conflict_candidate_evidence_ids: conflict_candidate_ids(evolution, &selected), + retrieved_evidence_ids: retrieved_evidence_ids.to_vec(), + selected_evidence_ids: selected_ids.to_vec(), + absent_evidence_ids: relevant_ids + .iter() + .filter(|id| !ingested.note_ids_by_evidence.contains_key(*id)) + .cloned() + .collect(), + retrieved_but_dropped_evidence_ids: relevant_ids + .iter() + .filter(|id| retrieved.contains(id.as_str()) && !selected.contains(id.as_str())) + .cloned() + .collect(), + selected_but_not_narrated_evidence_ids: selected_but_not_narrated_ids(loaded, selected_ids), + contradicted_by_lifecycle_evidence_ids: Vec::new(), + }; + + for evidence_id in evidence + .historical_loser_evidence_ids + .iter() + .chain(evidence.tombstone_evidence_ids.iter()) + .chain(evidence.invalidation_evidence_ids.iter()) + { + evidence_selection::push_unique( + &mut evidence.contradicted_by_lifecycle_evidence_ids, + evidence_id.clone(), + ); + } + + evidence +} + +pub(super) fn selected_subset(ids: &[String], selected: &BTreeSet<&str>) -> Vec { + ids.iter().filter(|id| selected.contains(id.as_str())).cloned().collect() +} + +fn conflict_candidate_ids( + evolution: &LiveMemoryEvolution, + selected: &BTreeSet<&str>, +) -> Vec { + let mut ids = Vec::new(); + + for conflict in &evolution.conflicts { + common::push_if_selected(&mut ids, conflict.current_evidence_id.as_str(), selected); + common::push_if_selected(&mut ids, conflict.historical_evidence_id.as_str(), selected); + + if let Some(evidence_id) = &conflict.resolved_by_evidence_id { + common::push_if_selected(&mut ids, evidence_id.as_str(), selected); + } + } + + ids +} + +fn selected_but_not_narrated_ids(loaded: &LoadedJob, selected_ids: &[String]) -> Vec { + let claims = evidence_selection::temporal_reconciliation_claims(loaded, selected_ids); + let narrated = claims + .iter() + .flat_map(|claim| { + claim + .get("evidence_ids") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + }) + .collect::>(); + + selected_ids.iter().filter(|id| !narrated.contains(id.as_str())).cloned().collect() +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/temporal/ids.rs b/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/temporal/ids.rs new file mode 100644 index 00000000..8e7ce943 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/temporal/ids.rs @@ -0,0 +1,42 @@ +use crate::evidence_selection::{self, LiveMemoryEvolution, LoadedJob}; + +pub(super) fn temporal_reconciliation_relevant_ids( + loaded: &LoadedJob, + evolution: &LiveMemoryEvolution, +) -> Vec { + let mut ids = Vec::new(); + + for evidence in &loaded.job.required_evidence { + evidence_selection::push_unique(&mut ids, evidence.evidence_id.clone()); + } + for evidence_id in &evolution.current_evidence_ids { + evidence_selection::push_unique(&mut ids, evidence_id.clone()); + } + for evidence_id in &evolution.historical_evidence_ids { + evidence_selection::push_unique(&mut ids, evidence_id.clone()); + } + for evidence_id in &evolution.tombstone_evidence_ids { + evidence_selection::push_unique(&mut ids, evidence_id.clone()); + } + for evidence_id in &evolution.invalidation_evidence_ids { + evidence_selection::push_unique(&mut ids, evidence_id.clone()); + } + for conflict in &evolution.conflicts { + evidence_selection::push_unique(&mut ids, conflict.current_evidence_id.clone()); + evidence_selection::push_unique(&mut ids, conflict.historical_evidence_id.clone()); + + if let Some(evidence_id) = &conflict.resolved_by_evidence_id { + evidence_selection::push_unique(&mut ids, evidence_id.clone()); + } + } + + if let Some(rationale) = &evolution.update_rationale + && rationale.available + { + for evidence_id in &rationale.evidence_ids { + evidence_selection::push_unique(&mut ids, evidence_id.clone()); + } + } + + ids +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/temporal/trace.rs b/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/temporal/trace.rs new file mode 100644 index 00000000..b0aee402 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/evidence_selection/temporal/trace.rs @@ -0,0 +1,97 @@ +use crate::evidence_selection::{ + BTreeSet, LiveMemoryEvolution, TemporalReconciliationMaterializationEvidence, TraceStageOutput, +}; + +pub(super) fn temporal_reconciliation_trace_stages( + evolution: &LiveMemoryEvolution, + retrieved_evidence_ids: &[String], + evidence: &TemporalReconciliationMaterializationEvidence, +) -> Vec { + let selected = + evidence.selected_evidence_ids.iter().map(String::as_str).collect::>(); + let retrieved = retrieved_evidence_ids.iter().map(String::as_str).collect::>(); + let expected_not_retrieved = evidence + .selected_evidence_ids + .iter() + .filter(|id| !retrieved.contains(id.as_str())) + .cloned() + .collect::>(); + + vec![ + TraceStageOutput { + stage_name: "live_adapter.retrieve".to_string(), + kept_evidence: retrieved_evidence_ids.to_vec(), + dropped_evidence: expected_not_retrieved, + demoted_evidence: Vec::new(), + distractor_evidence: evidence.absent_evidence_ids.clone(), + notes: + "Search output is compared with the temporal reconciliation evidence contract." + .to_string(), + }, + TraceStageOutput { + stage_name: "temporal_reconciliation.current_winner".to_string(), + kept_evidence: evidence.current_winner_evidence_ids.clone(), + dropped_evidence: unselected_subset(&evolution.current_evidence_ids, &selected), + demoted_evidence: Vec::new(), + distractor_evidence: Vec::new(), + notes: "Current evidence selected as the answer winner.".to_string(), + }, + TraceStageOutput { + stage_name: "temporal_reconciliation.historical_loser".to_string(), + kept_evidence: evidence.historical_loser_evidence_ids.clone(), + dropped_evidence: unselected_subset(&evolution.historical_evidence_ids, &selected), + demoted_evidence: evidence.historical_loser_evidence_ids.clone(), + distractor_evidence: Vec::new(), + notes: "Historical evidence preserved as history, not as the current answer." + .to_string(), + }, + TraceStageOutput { + stage_name: "temporal_reconciliation.supersession_rationale".to_string(), + kept_evidence: evidence.supersession_rationale_evidence_ids.clone(), + dropped_evidence: evolution + .update_rationale + .as_ref() + .map_or_else(Vec::new, |rationale| { + unselected_subset(&rationale.evidence_ids, &selected) + }), + demoted_evidence: Vec::new(), + distractor_evidence: Vec::new(), + notes: "Rationale evidence selected to explain why the older fact was superseded." + .to_string(), + }, + TraceStageOutput { + stage_name: "temporal_reconciliation.tombstone_invalidation".to_string(), + kept_evidence: evidence + .tombstone_evidence_ids + .iter() + .chain(evidence.invalidation_evidence_ids.iter()) + .cloned() + .collect(), + dropped_evidence: evolution + .tombstone_evidence_ids + .iter() + .chain(evolution.invalidation_evidence_ids.iter()) + .filter(|id| !selected.contains(id.as_str())) + .cloned() + .collect(), + demoted_evidence: Vec::new(), + distractor_evidence: Vec::new(), + notes: "Tombstone or TTL invalidation evidence remains answerable when present." + .to_string(), + }, + TraceStageOutput { + stage_name: "temporal_reconciliation.conflict_candidates".to_string(), + kept_evidence: evidence.conflict_candidate_evidence_ids.clone(), + dropped_evidence: evidence.retrieved_but_dropped_evidence_ids.clone(), + demoted_evidence: evidence.contradicted_by_lifecycle_evidence_ids.clone(), + distractor_evidence: evidence.selected_but_not_narrated_evidence_ids.clone(), + notes: + "Conflict candidates record selected, dropped, non-narrated, and lifecycle-demoted evidence." + .to_string(), + }, + ] +} + +fn unselected_subset(ids: &[String], selected: &BTreeSet<&str>) -> Vec { + ids.iter().filter(|id| !selected.contains(id.as_str())).cloned().collect() +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/model.rs b/apps/elf-eval/src/bin/real_world_live_adapter/model.rs index ccb8d770..fc4fd073 100644 --- a/apps/elf-eval/src/bin/real_world_live_adapter/model.rs +++ b/apps/elf-eval/src/bin/real_world_live_adapter/model.rs @@ -32,9 +32,8 @@ pub(super) use self::{ use crate::{ BoxFuture, ConsolidationInputRef, ConsolidationProposalInput, Deserialize, EmbeddingProvider, - EmbeddingProviderConfig, ExtractorProvider, HashMap, LlmProviderConfig, Map, Parser, Path, - PathBuf, ProviderConfig, RerankProvider, Serialize, Subcommand, Uuid, ValueEnum, embed_text, - serde_json, terms, + EmbeddingProviderConfig, ExtractorProvider, LlmProviderConfig, Map, Parser, PathBuf, + ProviderConfig, RerankProvider, Serialize, Subcommand, embed_text, serde_json, terms, }; pub(super) const JOB_SCHEMA: &str = "elf.real_world_job/v1"; diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/model/materialization.rs b/apps/elf-eval/src/bin/real_world_live_adapter/model/materialization.rs index 3fde4209..d6f6af2e 100644 --- a/apps/elf-eval/src/bin/real_world_live_adapter/model/materialization.rs +++ b/apps/elf-eval/src/bin/real_world_live_adapter/model/materialization.rs @@ -1,333 +1,26 @@ -use super::{HashMap, LiveCapturePolicy, LoadedJob, Path, Serialize, Uuid, ValueEnum, serde_json}; - -#[derive(Debug, Serialize)] -pub(crate) struct MaterializationEvidence { - pub(crate) schema: &'static str, - pub(crate) adapter_id: String, - pub(crate) adapter_kind: AdapterKind, - pub(crate) status: MaterializationStatus, - pub(crate) fixtures: String, - pub(crate) generated_fixtures: String, - pub(crate) command_evidence: Vec, - pub(crate) jobs: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) metadata: Option, -} - -#[derive(Debug, Serialize)] -pub(crate) struct CommandEvidence { - pub(crate) label: String, - pub(crate) status: MaterializationStatus, - pub(crate) command: String, - pub(crate) artifact: Option, - pub(crate) reason: String, -} - -#[derive(Debug, Serialize)] -pub(crate) struct MaterializedJobEvidence { - pub(crate) job_id: String, - pub(crate) suite: String, - pub(crate) title: String, - pub(crate) status: MaterializationStatus, - pub(crate) query: String, - pub(crate) evidence_ids: Vec, - pub(crate) returned_count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) indexing_latency_ms: Option, - pub(crate) latency_ms: f64, - pub(crate) trace_id: Option, - pub(crate) failure: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub(crate) source_mappings: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) operator_debug: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) capture: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) consolidation: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) knowledge: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) temporal_reconciliation: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) dreaming_readback: Option, -} - -#[derive(Clone, Debug, Serialize)] -pub(crate) struct OperatorDebugMaterializationEvidence { - pub(crate) trace_available: bool, - pub(crate) replay_command_available: bool, - pub(crate) candidate_drop_visibility: String, - pub(crate) repair_action_clarity: String, - pub(crate) raw_sql_needed: bool, -} - -#[derive(Clone, Debug, Default, Serialize)] -pub(crate) struct CaptureMaterializationEvidence { - pub(crate) stored_evidence_ids: Vec, - pub(crate) excluded_evidence_ids: Vec, - pub(crate) source_ids: Vec, - pub(crate) write_policy_audit_count: usize, - pub(crate) write_policy_exclusion_count: usize, - pub(crate) write_policy_redaction_count: usize, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub(crate) runtime_source_refs: Vec, -} - -#[derive(Clone, Debug, Default, Serialize)] -pub(crate) struct ConsolidationMaterializationEvidence { - pub(crate) run_id: Option, - pub(crate) proposal_ids: Vec, - pub(crate) source_lineage_count: usize, - pub(crate) unsupported_claim_flag_count: usize, - pub(crate) review_event_count: usize, - pub(crate) review_actions: Vec, - pub(crate) final_review_states: Vec, -} - -#[derive(Clone, Debug, Default, Serialize)] -pub(crate) struct KnowledgeMaterializationEvidence { - pub(crate) page_ids: Vec, - pub(crate) search_result_count: usize, - pub(crate) lint_finding_count: usize, - pub(crate) stale_source_finding_count: usize, - pub(crate) unsupported_claim_count: usize, - pub(crate) citation_count: usize, - pub(crate) source_ref_count: usize, - pub(crate) version_diff_available: bool, -} - -#[derive(Clone, Debug, Default, Serialize)] -pub(crate) struct TemporalReconciliationMaterializationEvidence { - pub(crate) current_winner_evidence_ids: Vec, - pub(crate) historical_loser_evidence_ids: Vec, - pub(crate) supersession_rationale_evidence_ids: Vec, - pub(crate) tombstone_evidence_ids: Vec, - pub(crate) invalidation_evidence_ids: Vec, - pub(crate) conflict_candidate_evidence_ids: Vec, - pub(crate) retrieved_evidence_ids: Vec, - pub(crate) selected_evidence_ids: Vec, - pub(crate) absent_evidence_ids: Vec, - pub(crate) retrieved_but_dropped_evidence_ids: Vec, - pub(crate) selected_but_not_narrated_evidence_ids: Vec, - pub(crate) contradicted_by_lifecycle_evidence_ids: Vec, -} - -#[derive(Clone, Debug, Default, Serialize)] -pub(crate) struct DreamingReadbackMaterializationEvidence { - pub(crate) artifact_kind: String, - pub(crate) runtime_path: String, - pub(crate) service_list_count: usize, - pub(crate) trace_id: Option, - pub(crate) generated_artifact_count: usize, - pub(crate) selected_source_refs: Vec, - pub(crate) missing_source_refs: Vec, - pub(crate) source_mutation_count: usize, - pub(crate) no_source_mutation_checked: bool, -} - -#[derive(Clone, Debug, Serialize)] -pub(crate) struct CaptureRuntimeSourceRefEvidence { - pub(crate) evidence_id: String, - pub(crate) source_ref: serde_json::Value, -} - -#[derive(Clone, Debug, Default)] -pub(crate) struct CaptureRuntimeEvidence { - pub(crate) items: Vec, -} -impl CaptureRuntimeEvidence { - pub(crate) fn item_for(&self, evidence_id: &str) -> Option<&CaptureRuntimeEvidenceItem> { - self.items.iter().find(|item| item.evidence_id == evidence_id) - } -} - -#[derive(Clone, Debug)] -pub(crate) struct CaptureRuntimeEvidenceItem { - pub(crate) evidence_id: String, - pub(crate) source_id: Option, - pub(crate) evidence_binding: Option, - pub(crate) write_policy_applied: bool, - pub(crate) capture_action: Option, - pub(crate) source_ref: serde_json::Value, -} - -#[derive(Debug, Serialize)] -pub(crate) struct AdapterResponseOutput { - pub(crate) adapter_id: String, - pub(crate) answer: AnswerOutput, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) consolidation: Option, -} - -#[derive(Debug, Serialize)] -pub(crate) struct AnswerOutput { - pub(crate) content: String, - pub(crate) evidence_ids: Vec, - pub(crate) claims: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub(crate) pages: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub(crate) memory_summaries: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub(crate) proactive_briefs: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub(crate) scheduled_tasks: Vec, - pub(crate) latency_ms: f64, - pub(crate) cost: CostOutput, - pub(crate) trace_explainability: TraceExplainabilityOutput, -} - -#[derive(Debug, Serialize)] -pub(crate) struct CostOutput { - pub(crate) currency: String, - pub(crate) amount: f64, - pub(crate) input_tokens: u64, - pub(crate) output_tokens: u64, -} - -#[derive(Debug, Serialize)] -pub(crate) struct TraceExplainabilityOutput { - pub(crate) trace_id: Option, - pub(crate) failure_stage: Option, - pub(crate) failure_reason: Option, - pub(crate) stages: Vec, -} - -#[derive(Clone, Debug, Serialize)] -pub(crate) struct TraceStageOutput { - pub(crate) stage_name: String, - pub(crate) kept_evidence: Vec, - pub(crate) dropped_evidence: Vec, - pub(crate) demoted_evidence: Vec, - pub(crate) distractor_evidence: Vec, - pub(crate) notes: String, -} - -#[derive(Debug)] -pub(crate) struct MaterializedJob { - pub(crate) response: AdapterResponseOutput, - pub(crate) evidence: MaterializedJobEvidence, - pub(crate) operator_debug: Option, -} - -#[derive(Debug)] -pub(crate) struct MaterializedJobInput { - pub(crate) content: String, - pub(crate) evidence_ids: Vec, - pub(crate) pages: Vec, - pub(crate) latency_ms: f64, - pub(crate) indexing_latency_ms: Option, - pub(crate) returned_count: usize, - pub(crate) trace_id: Option, - pub(crate) failure: Option, - pub(crate) source_mappings: Vec, - pub(crate) operator_debug: Option, - pub(crate) operator_debug_evidence: Option, - pub(crate) capture: Option, - pub(crate) capture_failure: Option, - pub(crate) consolidation_response: Option, - pub(crate) consolidation: Option, - pub(crate) knowledge: Option, - pub(crate) temporal_reconciliation: Option, - pub(crate) dreaming_readback: Option, - pub(crate) memory_summaries: Vec, - pub(crate) proactive_briefs: Vec, - pub(crate) scheduled_tasks: Vec, - pub(crate) trace_stages: Option>, -} - -#[derive(Debug)] -pub(crate) struct DreamingReadbackOutput { - pub(crate) content: String, - pub(crate) evidence_ids: Vec, - pub(crate) memory_summaries: Vec, - pub(crate) proactive_briefs: Vec, - pub(crate) scheduled_tasks: Vec, - pub(crate) materialization: DreamingReadbackMaterializationEvidence, - pub(crate) trace_stages: Vec, -} - -#[derive(Debug)] -pub(crate) struct SelectedEvidenceText { - pub(crate) content: String, - pub(crate) evidence_ids: Vec, -} - -#[derive(Debug)] -pub(crate) struct TemporalReconciliationSelection { - pub(crate) selected: SelectedEvidenceText, - pub(crate) evidence: TemporalReconciliationMaterializationEvidence, - pub(crate) trace_stages: Vec, -} - -pub(crate) struct SuiteMaterializationSelection { - pub(crate) selected: SelectedEvidenceText, - pub(crate) trace_stages: Option>, - pub(crate) dreaming_readback: Option, - pub(crate) memory_summaries: Vec, - pub(crate) proactive_briefs: Vec, - pub(crate) scheduled_tasks: Vec, -} - -pub(crate) struct SuiteMaterializationSelectionInput<'a> { - pub(crate) loaded: &'a LoadedJob, - pub(crate) ingested: &'a IngestedCorpus, - pub(crate) capture_failure: &'a Option, - pub(crate) selected: SelectedEvidenceText, - pub(crate) trace_stages: Option>, - pub(crate) knowledge: &'a Option, - pub(crate) consolidation: &'a Option, - pub(crate) dreaming_readback: Option, -} - -pub(crate) struct MaterializedOutput<'a> { - pub(crate) adapter_id: &'a str, - pub(crate) adapter_kind: AdapterKind, - pub(crate) fixtures: &'a Path, - pub(crate) out_fixtures: &'a Path, - pub(crate) evidence_out: &'a Path, - pub(crate) jobs: &'a [LoadedJob], - pub(crate) materialized: &'a [MaterializedJob], - pub(crate) command_evidence: Vec, - pub(crate) metadata: Option, -} - -#[derive(Debug)] -pub(crate) struct CorpusText { - pub(crate) evidence_id: String, - pub(crate) text: String, - pub(crate) capture: LiveCapturePolicy, -} - -#[derive(Debug, Default)] -pub(crate) struct IngestedCorpus { - pub(crate) capture: CaptureMaterializationEvidence, - pub(crate) note_ids_by_evidence: HashMap>, -} - -#[derive(Clone, Debug, Serialize)] -pub(crate) struct SourceMappingEvidence { - pub(crate) source: String, - pub(crate) evidence_ids: Vec, - pub(crate) mapping_status: String, - pub(crate) content_count: usize, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, ValueEnum)] -#[serde(rename_all = "snake_case")] -pub(crate) enum AdapterKind { - ElfServiceRuntime, - QmdCliRuntime, - LightragApiContextExport, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum MaterializationStatus { - Pass, - WrongResult, - Blocked, - Incomplete, - NotEncoded, -} +mod corpus; +mod enums; +mod evidence; +mod job; +mod output; + +pub(crate) use self::{ + corpus::{CorpusText, IngestedCorpus, SourceMappingEvidence}, + enums::{AdapterKind, MaterializationStatus}, + evidence::{ + CaptureMaterializationEvidence, CaptureRuntimeEvidence, CaptureRuntimeEvidenceItem, + CaptureRuntimeSourceRefEvidence, CommandEvidence, ConsolidationMaterializationEvidence, + DreamingReadbackMaterializationEvidence, KnowledgeMaterializationEvidence, + MaterializationEvidence, MaterializedJobEvidence, OperatorDebugMaterializationEvidence, + TemporalReconciliationMaterializationEvidence, + }, + job::{ + DreamingReadbackOutput, MaterializedJob, MaterializedJobInput, MaterializedOutput, + SelectedEvidenceText, SuiteMaterializationSelection, SuiteMaterializationSelectionInput, + TemporalReconciliationSelection, + }, + output::{ + AdapterResponseOutput, AnswerOutput, CostOutput, TraceExplainabilityOutput, + TraceStageOutput, + }, +}; diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/model/materialization/corpus.rs b/apps/elf-eval/src/bin/real_world_live_adapter/model/materialization/corpus.rs new file mode 100644 index 00000000..94f333ed --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/model/materialization/corpus.rs @@ -0,0 +1,24 @@ +use crate::{HashMap, Serialize, Uuid, model::LiveCapturePolicy}; + +use super::CaptureMaterializationEvidence; + +#[derive(Debug)] +pub(crate) struct CorpusText { + pub(crate) evidence_id: String, + pub(crate) text: String, + pub(crate) capture: LiveCapturePolicy, +} + +#[derive(Debug, Default)] +pub(crate) struct IngestedCorpus { + pub(crate) capture: CaptureMaterializationEvidence, + pub(crate) note_ids_by_evidence: HashMap>, +} + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct SourceMappingEvidence { + pub(crate) source: String, + pub(crate) evidence_ids: Vec, + pub(crate) mapping_status: String, + pub(crate) content_count: usize, +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/model/materialization/enums.rs b/apps/elf-eval/src/bin/real_world_live_adapter/model/materialization/enums.rs new file mode 100644 index 00000000..f082e4f0 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/model/materialization/enums.rs @@ -0,0 +1,19 @@ +use crate::{Serialize, ValueEnum}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, ValueEnum)] +#[serde(rename_all = "snake_case")] +pub(crate) enum AdapterKind { + ElfServiceRuntime, + QmdCliRuntime, + LightragApiContextExport, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum MaterializationStatus { + Pass, + WrongResult, + Blocked, + Incomplete, + NotEncoded, +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/model/materialization/evidence.rs b/apps/elf-eval/src/bin/real_world_live_adapter/model/materialization/evidence.rs new file mode 100644 index 00000000..1372e409 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/model/materialization/evidence.rs @@ -0,0 +1,156 @@ +use crate::{Serialize, Uuid, serde_json}; + +use super::{AdapterKind, MaterializationStatus, SourceMappingEvidence}; + +#[derive(Debug, Serialize)] +pub(crate) struct MaterializationEvidence { + pub(crate) schema: &'static str, + pub(crate) adapter_id: String, + pub(crate) adapter_kind: AdapterKind, + pub(crate) status: MaterializationStatus, + pub(crate) fixtures: String, + pub(crate) generated_fixtures: String, + pub(crate) command_evidence: Vec, + pub(crate) jobs: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) metadata: Option, +} + +#[derive(Debug, Serialize)] +pub(crate) struct CommandEvidence { + pub(crate) label: String, + pub(crate) status: MaterializationStatus, + pub(crate) command: String, + pub(crate) artifact: Option, + pub(crate) reason: String, +} + +#[derive(Debug, Serialize)] +pub(crate) struct MaterializedJobEvidence { + pub(crate) job_id: String, + pub(crate) suite: String, + pub(crate) title: String, + pub(crate) status: MaterializationStatus, + pub(crate) query: String, + pub(crate) evidence_ids: Vec, + pub(crate) returned_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) indexing_latency_ms: Option, + pub(crate) latency_ms: f64, + pub(crate) trace_id: Option, + pub(crate) failure: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) source_mappings: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) operator_debug: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) capture: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) consolidation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) knowledge: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) temporal_reconciliation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) dreaming_readback: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct OperatorDebugMaterializationEvidence { + pub(crate) trace_available: bool, + pub(crate) replay_command_available: bool, + pub(crate) candidate_drop_visibility: String, + pub(crate) repair_action_clarity: String, + pub(crate) raw_sql_needed: bool, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub(crate) struct CaptureMaterializationEvidence { + pub(crate) stored_evidence_ids: Vec, + pub(crate) excluded_evidence_ids: Vec, + pub(crate) source_ids: Vec, + pub(crate) write_policy_audit_count: usize, + pub(crate) write_policy_exclusion_count: usize, + pub(crate) write_policy_redaction_count: usize, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) runtime_source_refs: Vec, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub(crate) struct ConsolidationMaterializationEvidence { + pub(crate) run_id: Option, + pub(crate) proposal_ids: Vec, + pub(crate) source_lineage_count: usize, + pub(crate) unsupported_claim_flag_count: usize, + pub(crate) review_event_count: usize, + pub(crate) review_actions: Vec, + pub(crate) final_review_states: Vec, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub(crate) struct KnowledgeMaterializationEvidence { + pub(crate) page_ids: Vec, + pub(crate) search_result_count: usize, + pub(crate) lint_finding_count: usize, + pub(crate) stale_source_finding_count: usize, + pub(crate) unsupported_claim_count: usize, + pub(crate) citation_count: usize, + pub(crate) source_ref_count: usize, + pub(crate) version_diff_available: bool, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub(crate) struct TemporalReconciliationMaterializationEvidence { + pub(crate) current_winner_evidence_ids: Vec, + pub(crate) historical_loser_evidence_ids: Vec, + pub(crate) supersession_rationale_evidence_ids: Vec, + pub(crate) tombstone_evidence_ids: Vec, + pub(crate) invalidation_evidence_ids: Vec, + pub(crate) conflict_candidate_evidence_ids: Vec, + pub(crate) retrieved_evidence_ids: Vec, + pub(crate) selected_evidence_ids: Vec, + pub(crate) absent_evidence_ids: Vec, + pub(crate) retrieved_but_dropped_evidence_ids: Vec, + pub(crate) selected_but_not_narrated_evidence_ids: Vec, + pub(crate) contradicted_by_lifecycle_evidence_ids: Vec, +} + +#[derive(Clone, Debug, Default, Serialize)] +pub(crate) struct DreamingReadbackMaterializationEvidence { + pub(crate) artifact_kind: String, + pub(crate) runtime_path: String, + pub(crate) service_list_count: usize, + pub(crate) trace_id: Option, + pub(crate) generated_artifact_count: usize, + pub(crate) selected_source_refs: Vec, + pub(crate) missing_source_refs: Vec, + pub(crate) source_mutation_count: usize, + pub(crate) no_source_mutation_checked: bool, +} + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct CaptureRuntimeSourceRefEvidence { + pub(crate) evidence_id: String, + pub(crate) source_ref: serde_json::Value, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct CaptureRuntimeEvidence { + pub(crate) items: Vec, +} + +impl CaptureRuntimeEvidence { + pub(crate) fn item_for(&self, evidence_id: &str) -> Option<&CaptureRuntimeEvidenceItem> { + self.items.iter().find(|item| item.evidence_id == evidence_id) + } +} + +#[derive(Clone, Debug)] +pub(crate) struct CaptureRuntimeEvidenceItem { + pub(crate) evidence_id: String, + pub(crate) source_id: Option, + pub(crate) evidence_binding: Option, + pub(crate) write_policy_applied: bool, + pub(crate) capture_action: Option, + pub(crate) source_ref: serde_json::Value, +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/model/materialization/job.rs b/apps/elf-eval/src/bin/real_world_live_adapter/model/materialization/job.rs new file mode 100644 index 00000000..bac68439 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/model/materialization/job.rs @@ -0,0 +1,98 @@ +use crate::{LoadedJob, Path, Uuid, serde_json}; + +use super::{ + AdapterKind, AdapterResponseOutput, CaptureMaterializationEvidence, CommandEvidence, + ConsolidationMaterializationEvidence, DreamingReadbackMaterializationEvidence, IngestedCorpus, + KnowledgeMaterializationEvidence, MaterializedJobEvidence, + OperatorDebugMaterializationEvidence, SourceMappingEvidence, + TemporalReconciliationMaterializationEvidence, TraceStageOutput, +}; + +#[derive(Debug)] +pub(crate) struct MaterializedJob { + pub(crate) response: AdapterResponseOutput, + pub(crate) evidence: MaterializedJobEvidence, + pub(crate) operator_debug: Option, +} + +#[derive(Debug)] +pub(crate) struct MaterializedJobInput { + pub(crate) content: String, + pub(crate) evidence_ids: Vec, + pub(crate) pages: Vec, + pub(crate) latency_ms: f64, + pub(crate) indexing_latency_ms: Option, + pub(crate) returned_count: usize, + pub(crate) trace_id: Option, + pub(crate) failure: Option, + pub(crate) source_mappings: Vec, + pub(crate) operator_debug: Option, + pub(crate) operator_debug_evidence: Option, + pub(crate) capture: Option, + pub(crate) capture_failure: Option, + pub(crate) consolidation_response: Option, + pub(crate) consolidation: Option, + pub(crate) knowledge: Option, + pub(crate) temporal_reconciliation: Option, + pub(crate) dreaming_readback: Option, + pub(crate) memory_summaries: Vec, + pub(crate) proactive_briefs: Vec, + pub(crate) scheduled_tasks: Vec, + pub(crate) trace_stages: Option>, +} + +#[derive(Debug)] +pub(crate) struct DreamingReadbackOutput { + pub(crate) content: String, + pub(crate) evidence_ids: Vec, + pub(crate) memory_summaries: Vec, + pub(crate) proactive_briefs: Vec, + pub(crate) scheduled_tasks: Vec, + pub(crate) materialization: DreamingReadbackMaterializationEvidence, + pub(crate) trace_stages: Vec, +} + +#[derive(Debug)] +pub(crate) struct SelectedEvidenceText { + pub(crate) content: String, + pub(crate) evidence_ids: Vec, +} + +#[derive(Debug)] +pub(crate) struct TemporalReconciliationSelection { + pub(crate) selected: SelectedEvidenceText, + pub(crate) evidence: TemporalReconciliationMaterializationEvidence, + pub(crate) trace_stages: Vec, +} + +pub(crate) struct SuiteMaterializationSelection { + pub(crate) selected: SelectedEvidenceText, + pub(crate) trace_stages: Option>, + pub(crate) dreaming_readback: Option, + pub(crate) memory_summaries: Vec, + pub(crate) proactive_briefs: Vec, + pub(crate) scheduled_tasks: Vec, +} + +pub(crate) struct SuiteMaterializationSelectionInput<'a> { + pub(crate) loaded: &'a LoadedJob, + pub(crate) ingested: &'a IngestedCorpus, + pub(crate) capture_failure: &'a Option, + pub(crate) selected: SelectedEvidenceText, + pub(crate) trace_stages: Option>, + pub(crate) knowledge: &'a Option, + pub(crate) consolidation: &'a Option, + pub(crate) dreaming_readback: Option, +} + +pub(crate) struct MaterializedOutput<'a> { + pub(crate) adapter_id: &'a str, + pub(crate) adapter_kind: AdapterKind, + pub(crate) fixtures: &'a Path, + pub(crate) out_fixtures: &'a Path, + pub(crate) evidence_out: &'a Path, + pub(crate) jobs: &'a [LoadedJob], + pub(crate) materialized: &'a [MaterializedJob], + pub(crate) command_evidence: Vec, + pub(crate) metadata: Option, +} diff --git a/apps/elf-eval/src/bin/real_world_live_adapter/model/materialization/output.rs b/apps/elf-eval/src/bin/real_world_live_adapter/model/materialization/output.rs new file mode 100644 index 00000000..295ced34 --- /dev/null +++ b/apps/elf-eval/src/bin/real_world_live_adapter/model/materialization/output.rs @@ -0,0 +1,53 @@ +use crate::{Serialize, serde_json}; + +#[derive(Debug, Serialize)] +pub(crate) struct AdapterResponseOutput { + pub(crate) adapter_id: String, + pub(crate) answer: AnswerOutput, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) consolidation: Option, +} + +#[derive(Debug, Serialize)] +pub(crate) struct AnswerOutput { + pub(crate) content: String, + pub(crate) evidence_ids: Vec, + pub(crate) claims: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) pages: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) memory_summaries: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) proactive_briefs: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) scheduled_tasks: Vec, + pub(crate) latency_ms: f64, + pub(crate) cost: CostOutput, + pub(crate) trace_explainability: TraceExplainabilityOutput, +} + +#[derive(Debug, Serialize)] +pub(crate) struct CostOutput { + pub(crate) currency: String, + pub(crate) amount: f64, + pub(crate) input_tokens: u64, + pub(crate) output_tokens: u64, +} + +#[derive(Debug, Serialize)] +pub(crate) struct TraceExplainabilityOutput { + pub(crate) trace_id: Option, + pub(crate) failure_stage: Option, + pub(crate) failure_reason: Option, + pub(crate) stages: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct TraceStageOutput { + pub(crate) stage_name: String, + pub(crate) kept_evidence: Vec, + pub(crate) dropped_evidence: Vec, + pub(crate) demoted_evidence: Vec, + pub(crate) distractor_evidence: Vec, + pub(crate) notes: String, +} diff --git a/apps/elf-eval/src/bin/trace_gate_export.rs b/apps/elf-eval/src/bin/trace_gate_export/main.rs similarity index 87% rename from apps/elf-eval/src/bin/trace_gate_export.rs rename to apps/elf-eval/src/bin/trace_gate_export/main.rs index 379df1be..fb2283c6 100644 --- a/apps/elf-eval/src/bin/trace_gate_export.rs +++ b/apps/elf-eval/src/bin/trace_gate_export/main.rs @@ -2,11 +2,11 @@ //! CLI for exporting trace fixtures used by regression gates. -#[path = "trace_gate_export/cli.rs"] mod cli; -#[path = "trace_gate_export/fetch.rs"] mod fetch; -#[path = "trace_gate_export/render.rs"] mod render; -#[path = "trace_gate_export/rows.rs"] mod rows; -#[path = "trace_gate_export/sql.rs"] mod sql; +mod cli; +mod fetch; +mod render; +mod rows; +mod sql; use std::fs; diff --git a/apps/elf-eval/src/bin/trace_gate_export/render.rs b/apps/elf-eval/src/bin/trace_gate_export/render.rs index ed315ced..c5c3f27b 100644 --- a/apps/elf-eval/src/bin/trace_gate_export/render.rs +++ b/apps/elf-eval/src/bin/trace_gate_export/render.rs @@ -1,9 +1,15 @@ +mod candidates; +mod items; +mod preamble; +mod stage_items; +mod stages; +mod traces; + use color_eyre::Result; use crate::{ cli::Args, rows::{CandidateRow, ItemRow, StageItemRow, StageRow, TraceRow}, - sql::{self}, }; pub(super) fn render_fixture_sql( @@ -16,278 +22,14 @@ pub(super) fn render_fixture_sql( ) -> Result { let mut out = String::new(); - render_preamble(args, &mut out); - render_traces(&mut out, traces)?; - render_candidates(&mut out, candidates)?; - render_items(&mut out, items)?; - render_stages(&mut out, stages)?; - render_stage_items(&mut out, stage_items)?; + preamble::render_preamble(args, &mut out); + traces::render_traces(&mut out, traces)?; + candidates::render_candidates(&mut out, candidates)?; + items::render_items(&mut out, items)?; + stages::render_stages(&mut out, stages)?; + stage_items::render_stage_items(&mut out, stage_items)?; out.push_str("COMMIT;\n"); Ok(out) } - -fn render_preamble(args: &Args, out: &mut String) { - out.push_str("-- Generated by `elf-eval trace_gate_export`.\n"); - out.push_str(&format!( - "-- trace_ids: {}\n", - args.trace_id.iter().map(|id| id.to_string()).collect::>().join(", ") - )); - out.push_str("BEGIN;\n\n"); -} - -fn render_traces(out: &mut String, traces: &[TraceRow]) -> Result<()> { - if traces.is_empty() { - return Ok(()); - } - - out.push_str("INSERT INTO search_traces (\n"); - out.push_str(" trace_id,\n"); - out.push_str(" tenant_id,\n"); - out.push_str(" project_id,\n"); - out.push_str(" agent_id,\n"); - out.push_str(" read_profile,\n"); - out.push_str(" query,\n"); - out.push_str(" expansion_mode,\n"); - out.push_str(" expanded_queries,\n"); - out.push_str(" allowed_scopes,\n"); - out.push_str(" candidate_count,\n"); - out.push_str(" top_k,\n"); - out.push_str(" config_snapshot,\n"); - out.push_str(" trace_version,\n"); - out.push_str(" created_at,\n"); - out.push_str(" expires_at\n"); - out.push_str(")\nVALUES\n"); - - for (idx, row) in traces.iter().enumerate() { - out.push_str(" ("); - out.push_str(&sql::sql_uuid(&row.trace_id)); - out.push_str(", "); - out.push_str(&sql::sql_text(&row.tenant_id)); - out.push_str(", "); - out.push_str(&sql::sql_text(&row.project_id)); - out.push_str(", "); - out.push_str(&sql::sql_text(&row.agent_id)); - out.push_str(", "); - out.push_str(&sql::sql_text(&row.read_profile)); - out.push_str(", "); - out.push_str(&sql::sql_text(&row.query)); - out.push_str(", "); - out.push_str(&sql::sql_text(&row.expansion_mode)); - out.push_str(", "); - out.push_str(&sql::sql_jsonb(&row.expanded_queries)?); - out.push_str(", "); - out.push_str(&sql::sql_jsonb(&row.allowed_scopes)?); - out.push_str(", "); - out.push_str(&row.candidate_count.to_string()); - out.push_str(", "); - out.push_str(&row.top_k.to_string()); - out.push_str(", "); - out.push_str(&sql::sql_jsonb(&row.config_snapshot)?); - out.push_str(", "); - out.push_str(&row.trace_version.to_string()); - out.push_str(", "); - out.push_str(&sql::sql_timestamptz(&row.created_at)?); - out.push_str(", "); - out.push_str(&sql::sql_timestamptz(&row.expires_at)?); - out.push(')'); - - if idx + 1 == traces.len() { - out.push_str(";\n\n"); - } else { - out.push_str(",\n"); - } - } - - Ok(()) -} - -fn render_candidates(out: &mut String, candidates: &[CandidateRow]) -> Result<()> { - if candidates.is_empty() { - return Ok(()); - } - - out.push_str("INSERT INTO search_trace_candidates (\n"); - out.push_str(" candidate_id,\n"); - out.push_str(" trace_id,\n"); - out.push_str(" note_id,\n"); - out.push_str(" chunk_id,\n"); - out.push_str(" chunk_index,\n"); - out.push_str(" snippet,\n"); - out.push_str(" candidate_snapshot,\n"); - out.push_str(" retrieval_rank,\n"); - out.push_str(" rerank_score,\n"); - out.push_str(" note_scope,\n"); - out.push_str(" note_importance,\n"); - out.push_str(" note_updated_at,\n"); - out.push_str(" note_hit_count,\n"); - out.push_str(" note_last_hit_at,\n"); - out.push_str(" created_at,\n"); - out.push_str(" expires_at\n"); - out.push_str(")\nVALUES\n"); - - for (idx, row) in candidates.iter().enumerate() { - out.push_str(" ("); - out.push_str(&sql::sql_uuid(&row.candidate_id)); - out.push_str(", "); - out.push_str(&sql::sql_uuid(&row.trace_id)); - out.push_str(", "); - out.push_str(&sql::sql_uuid(&row.note_id)); - out.push_str(", "); - out.push_str(&sql::sql_uuid(&row.chunk_id)); - out.push_str(", "); - out.push_str(&row.chunk_index.to_string()); - out.push_str(", "); - out.push_str(&sql::sql_text(&row.snippet)); - out.push_str(", "); - out.push_str(&sql::sql_jsonb(&row.candidate_snapshot)?); - out.push_str(", "); - out.push_str(&row.retrieval_rank.to_string()); - out.push_str(", "); - out.push_str(&sql::sql_f32(row.rerank_score)); - out.push_str(", "); - out.push_str(&sql::sql_text(&row.note_scope)); - out.push_str(", "); - out.push_str(&sql::sql_f32(row.note_importance)); - out.push_str(", "); - out.push_str(&sql::sql_timestamptz(&row.note_updated_at)?); - out.push_str(", "); - out.push_str(&row.note_hit_count.to_string()); - out.push_str(", "); - out.push_str(&sql::sql_opt_timestamptz(&row.note_last_hit_at)?); - out.push_str(", "); - out.push_str(&sql::sql_timestamptz(&row.created_at)?); - out.push_str(", "); - out.push_str(&sql::sql_timestamptz(&row.expires_at)?); - out.push(')'); - - if idx + 1 == candidates.len() { - out.push_str(";\n\n"); - } else { - out.push_str(",\n"); - } - } - - Ok(()) -} - -fn render_items(out: &mut String, items: &[ItemRow]) -> Result<()> { - if items.is_empty() { - return Ok(()); - } - - out.push_str("INSERT INTO search_trace_items (\n"); - out.push_str(" item_id,\n"); - out.push_str(" trace_id,\n"); - out.push_str(" note_id,\n"); - out.push_str(" chunk_id,\n"); - out.push_str(" rank,\n"); - out.push_str(" final_score,\n"); - out.push_str(" explain\n"); - out.push_str(")\nVALUES\n"); - - for (idx, row) in items.iter().enumerate() { - out.push_str(" ("); - out.push_str(&sql::sql_uuid(&row.item_id)); - out.push_str(", "); - out.push_str(&sql::sql_uuid(&row.trace_id)); - out.push_str(", "); - out.push_str(&sql::sql_uuid(&row.note_id)); - out.push_str(", "); - out.push_str(&sql::sql_opt_uuid(&row.chunk_id)); - out.push_str(", "); - out.push_str(&row.rank.to_string()); - out.push_str(", "); - out.push_str(&sql::sql_f32(row.final_score)); - out.push_str(", "); - out.push_str(&sql::sql_jsonb(&row.explain)?); - out.push(')'); - - if idx + 1 == items.len() { - out.push_str(";\n\n"); - } else { - out.push_str(",\n"); - } - } - - Ok(()) -} - -fn render_stages(out: &mut String, stages: &[StageRow]) -> Result<()> { - if stages.is_empty() { - return Ok(()); - } - - out.push_str("INSERT INTO search_trace_stages (\n"); - out.push_str(" stage_id,\n"); - out.push_str(" trace_id,\n"); - out.push_str(" stage_order,\n"); - out.push_str(" stage_name,\n"); - out.push_str(" stage_payload,\n"); - out.push_str(" created_at\n"); - out.push_str(")\nVALUES\n"); - - for (idx, row) in stages.iter().enumerate() { - out.push_str(" ("); - out.push_str(&sql::sql_uuid(&row.stage_id)); - out.push_str(", "); - out.push_str(&sql::sql_uuid(&row.trace_id)); - out.push_str(", "); - out.push_str(&row.stage_order.to_string()); - out.push_str(", "); - out.push_str(&sql::sql_text(&row.stage_name)); - out.push_str(", "); - out.push_str(&sql::sql_jsonb(&row.stage_payload)?); - out.push_str(", "); - out.push_str(&sql::sql_timestamptz(&row.created_at)?); - out.push(')'); - - if idx + 1 == stages.len() { - out.push_str(";\n\n"); - } else { - out.push_str(",\n"); - } - } - - Ok(()) -} - -fn render_stage_items(out: &mut String, stage_items: &[StageItemRow]) -> Result<()> { - if stage_items.is_empty() { - return Ok(()); - } - - out.push_str("INSERT INTO search_trace_stage_items (\n"); - out.push_str(" id,\n"); - out.push_str(" stage_id,\n"); - out.push_str(" item_id,\n"); - out.push_str(" note_id,\n"); - out.push_str(" chunk_id,\n"); - out.push_str(" metrics\n"); - out.push_str(")\nVALUES\n"); - - for (idx, row) in stage_items.iter().enumerate() { - out.push_str(" ("); - out.push_str(&sql::sql_uuid(&row.id)); - out.push_str(", "); - out.push_str(&sql::sql_uuid(&row.stage_id)); - out.push_str(", "); - out.push_str(&sql::sql_opt_uuid(&row.item_id)); - out.push_str(", "); - out.push_str(&sql::sql_opt_uuid(&row.note_id)); - out.push_str(", "); - out.push_str(&sql::sql_opt_uuid(&row.chunk_id)); - out.push_str(", "); - out.push_str(&sql::sql_jsonb(&row.metrics)?); - out.push(')'); - - if idx + 1 == stage_items.len() { - out.push_str(";\n\n"); - } else { - out.push_str(",\n"); - } - } - - Ok(()) -} diff --git a/apps/elf-eval/src/bin/trace_gate_export/render/candidates.rs b/apps/elf-eval/src/bin/trace_gate_export/render/candidates.rs new file mode 100644 index 00000000..7bb5d56a --- /dev/null +++ b/apps/elf-eval/src/bin/trace_gate_export/render/candidates.rs @@ -0,0 +1,72 @@ +use color_eyre::Result; + +use crate::{rows::CandidateRow, sql}; + +pub(super) fn render_candidates(out: &mut String, candidates: &[CandidateRow]) -> Result<()> { + if candidates.is_empty() { + return Ok(()); + } + + out.push_str("INSERT INTO search_trace_candidates (\n"); + out.push_str(" candidate_id,\n"); + out.push_str(" trace_id,\n"); + out.push_str(" note_id,\n"); + out.push_str(" chunk_id,\n"); + out.push_str(" chunk_index,\n"); + out.push_str(" snippet,\n"); + out.push_str(" candidate_snapshot,\n"); + out.push_str(" retrieval_rank,\n"); + out.push_str(" rerank_score,\n"); + out.push_str(" note_scope,\n"); + out.push_str(" note_importance,\n"); + out.push_str(" note_updated_at,\n"); + out.push_str(" note_hit_count,\n"); + out.push_str(" note_last_hit_at,\n"); + out.push_str(" created_at,\n"); + out.push_str(" expires_at\n"); + out.push_str(")\nVALUES\n"); + + for (idx, row) in candidates.iter().enumerate() { + out.push_str(" ("); + out.push_str(&sql::sql_uuid(&row.candidate_id)); + out.push_str(", "); + out.push_str(&sql::sql_uuid(&row.trace_id)); + out.push_str(", "); + out.push_str(&sql::sql_uuid(&row.note_id)); + out.push_str(", "); + out.push_str(&sql::sql_uuid(&row.chunk_id)); + out.push_str(", "); + out.push_str(&row.chunk_index.to_string()); + out.push_str(", "); + out.push_str(&sql::sql_text(&row.snippet)); + out.push_str(", "); + out.push_str(&sql::sql_jsonb(&row.candidate_snapshot)?); + out.push_str(", "); + out.push_str(&row.retrieval_rank.to_string()); + out.push_str(", "); + out.push_str(&sql::sql_f32(row.rerank_score)); + out.push_str(", "); + out.push_str(&sql::sql_text(&row.note_scope)); + out.push_str(", "); + out.push_str(&sql::sql_f32(row.note_importance)); + out.push_str(", "); + out.push_str(&sql::sql_timestamptz(&row.note_updated_at)?); + out.push_str(", "); + out.push_str(&row.note_hit_count.to_string()); + out.push_str(", "); + out.push_str(&sql::sql_opt_timestamptz(&row.note_last_hit_at)?); + out.push_str(", "); + out.push_str(&sql::sql_timestamptz(&row.created_at)?); + out.push_str(", "); + out.push_str(&sql::sql_timestamptz(&row.expires_at)?); + out.push(')'); + + if idx + 1 == candidates.len() { + out.push_str(";\n\n"); + } else { + out.push_str(",\n"); + } + } + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/trace_gate_export/render/items.rs b/apps/elf-eval/src/bin/trace_gate_export/render/items.rs new file mode 100644 index 00000000..442941ed --- /dev/null +++ b/apps/elf-eval/src/bin/trace_gate_export/render/items.rs @@ -0,0 +1,45 @@ +use color_eyre::Result; + +use crate::{rows::ItemRow, sql}; + +pub(super) fn render_items(out: &mut String, items: &[ItemRow]) -> Result<()> { + if items.is_empty() { + return Ok(()); + } + + out.push_str("INSERT INTO search_trace_items (\n"); + out.push_str(" item_id,\n"); + out.push_str(" trace_id,\n"); + out.push_str(" note_id,\n"); + out.push_str(" chunk_id,\n"); + out.push_str(" rank,\n"); + out.push_str(" final_score,\n"); + out.push_str(" explain\n"); + out.push_str(")\nVALUES\n"); + + for (idx, row) in items.iter().enumerate() { + out.push_str(" ("); + out.push_str(&sql::sql_uuid(&row.item_id)); + out.push_str(", "); + out.push_str(&sql::sql_uuid(&row.trace_id)); + out.push_str(", "); + out.push_str(&sql::sql_uuid(&row.note_id)); + out.push_str(", "); + out.push_str(&sql::sql_opt_uuid(&row.chunk_id)); + out.push_str(", "); + out.push_str(&row.rank.to_string()); + out.push_str(", "); + out.push_str(&sql::sql_f32(row.final_score)); + out.push_str(", "); + out.push_str(&sql::sql_jsonb(&row.explain)?); + out.push(')'); + + if idx + 1 == items.len() { + out.push_str(";\n\n"); + } else { + out.push_str(",\n"); + } + } + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/trace_gate_export/render/preamble.rs b/apps/elf-eval/src/bin/trace_gate_export/render/preamble.rs new file mode 100644 index 00000000..b0c779bd --- /dev/null +++ b/apps/elf-eval/src/bin/trace_gate_export/render/preamble.rs @@ -0,0 +1,10 @@ +use crate::cli::Args; + +pub(super) fn render_preamble(args: &Args, out: &mut String) { + out.push_str("-- Generated by `elf-eval trace_gate_export`.\n"); + out.push_str(&format!( + "-- trace_ids: {}\n", + args.trace_id.iter().map(|id| id.to_string()).collect::>().join(", ") + )); + out.push_str("BEGIN;\n\n"); +} diff --git a/apps/elf-eval/src/bin/trace_gate_export/render/stage_items.rs b/apps/elf-eval/src/bin/trace_gate_export/render/stage_items.rs new file mode 100644 index 00000000..dba7e151 --- /dev/null +++ b/apps/elf-eval/src/bin/trace_gate_export/render/stage_items.rs @@ -0,0 +1,42 @@ +use color_eyre::Result; + +use crate::{rows::StageItemRow, sql}; + +pub(super) fn render_stage_items(out: &mut String, stage_items: &[StageItemRow]) -> Result<()> { + if stage_items.is_empty() { + return Ok(()); + } + + out.push_str("INSERT INTO search_trace_stage_items (\n"); + out.push_str(" id,\n"); + out.push_str(" stage_id,\n"); + out.push_str(" item_id,\n"); + out.push_str(" note_id,\n"); + out.push_str(" chunk_id,\n"); + out.push_str(" metrics\n"); + out.push_str(")\nVALUES\n"); + + for (idx, row) in stage_items.iter().enumerate() { + out.push_str(" ("); + out.push_str(&sql::sql_uuid(&row.id)); + out.push_str(", "); + out.push_str(&sql::sql_uuid(&row.stage_id)); + out.push_str(", "); + out.push_str(&sql::sql_opt_uuid(&row.item_id)); + out.push_str(", "); + out.push_str(&sql::sql_opt_uuid(&row.note_id)); + out.push_str(", "); + out.push_str(&sql::sql_opt_uuid(&row.chunk_id)); + out.push_str(", "); + out.push_str(&sql::sql_jsonb(&row.metrics)?); + out.push(')'); + + if idx + 1 == stage_items.len() { + out.push_str(";\n\n"); + } else { + out.push_str(",\n"); + } + } + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/trace_gate_export/render/stages.rs b/apps/elf-eval/src/bin/trace_gate_export/render/stages.rs new file mode 100644 index 00000000..8612bb8e --- /dev/null +++ b/apps/elf-eval/src/bin/trace_gate_export/render/stages.rs @@ -0,0 +1,42 @@ +use color_eyre::Result; + +use crate::{rows::StageRow, sql}; + +pub(super) fn render_stages(out: &mut String, stages: &[StageRow]) -> Result<()> { + if stages.is_empty() { + return Ok(()); + } + + out.push_str("INSERT INTO search_trace_stages (\n"); + out.push_str(" stage_id,\n"); + out.push_str(" trace_id,\n"); + out.push_str(" stage_order,\n"); + out.push_str(" stage_name,\n"); + out.push_str(" stage_payload,\n"); + out.push_str(" created_at\n"); + out.push_str(")\nVALUES\n"); + + for (idx, row) in stages.iter().enumerate() { + out.push_str(" ("); + out.push_str(&sql::sql_uuid(&row.stage_id)); + out.push_str(", "); + out.push_str(&sql::sql_uuid(&row.trace_id)); + out.push_str(", "); + out.push_str(&row.stage_order.to_string()); + out.push_str(", "); + out.push_str(&sql::sql_text(&row.stage_name)); + out.push_str(", "); + out.push_str(&sql::sql_jsonb(&row.stage_payload)?); + out.push_str(", "); + out.push_str(&sql::sql_timestamptz(&row.created_at)?); + out.push(')'); + + if idx + 1 == stages.len() { + out.push_str(";\n\n"); + } else { + out.push_str(",\n"); + } + } + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/trace_gate_export/render/traces.rs b/apps/elf-eval/src/bin/trace_gate_export/render/traces.rs new file mode 100644 index 00000000..736fcb04 --- /dev/null +++ b/apps/elf-eval/src/bin/trace_gate_export/render/traces.rs @@ -0,0 +1,69 @@ +use color_eyre::Result; + +use crate::{rows::TraceRow, sql}; + +pub(super) fn render_traces(out: &mut String, traces: &[TraceRow]) -> Result<()> { + if traces.is_empty() { + return Ok(()); + } + + out.push_str("INSERT INTO search_traces (\n"); + out.push_str(" trace_id,\n"); + out.push_str(" tenant_id,\n"); + out.push_str(" project_id,\n"); + out.push_str(" agent_id,\n"); + out.push_str(" read_profile,\n"); + out.push_str(" query,\n"); + out.push_str(" expansion_mode,\n"); + out.push_str(" expanded_queries,\n"); + out.push_str(" allowed_scopes,\n"); + out.push_str(" candidate_count,\n"); + out.push_str(" top_k,\n"); + out.push_str(" config_snapshot,\n"); + out.push_str(" trace_version,\n"); + out.push_str(" created_at,\n"); + out.push_str(" expires_at\n"); + out.push_str(")\nVALUES\n"); + + for (idx, row) in traces.iter().enumerate() { + out.push_str(" ("); + out.push_str(&sql::sql_uuid(&row.trace_id)); + out.push_str(", "); + out.push_str(&sql::sql_text(&row.tenant_id)); + out.push_str(", "); + out.push_str(&sql::sql_text(&row.project_id)); + out.push_str(", "); + out.push_str(&sql::sql_text(&row.agent_id)); + out.push_str(", "); + out.push_str(&sql::sql_text(&row.read_profile)); + out.push_str(", "); + out.push_str(&sql::sql_text(&row.query)); + out.push_str(", "); + out.push_str(&sql::sql_text(&row.expansion_mode)); + out.push_str(", "); + out.push_str(&sql::sql_jsonb(&row.expanded_queries)?); + out.push_str(", "); + out.push_str(&sql::sql_jsonb(&row.allowed_scopes)?); + out.push_str(", "); + out.push_str(&row.candidate_count.to_string()); + out.push_str(", "); + out.push_str(&row.top_k.to_string()); + out.push_str(", "); + out.push_str(&sql::sql_jsonb(&row.config_snapshot)?); + out.push_str(", "); + out.push_str(&row.trace_version.to_string()); + out.push_str(", "); + out.push_str(&sql::sql_timestamptz(&row.created_at)?); + out.push_str(", "); + out.push_str(&sql::sql_timestamptz(&row.expires_at)?); + out.push(')'); + + if idx + 1 == traces.len() { + out.push_str(";\n\n"); + } else { + out.push_str(",\n"); + } + } + + Ok(()) +} diff --git a/apps/elf-eval/src/bin/trace_regression_gate.rs b/apps/elf-eval/src/bin/trace_regression_gate/main.rs similarity index 83% rename from apps/elf-eval/src/bin/trace_regression_gate.rs rename to apps/elf-eval/src/bin/trace_regression_gate/main.rs index f610753e..b0fe8d02 100644 --- a/apps/elf-eval/src/bin/trace_regression_gate.rs +++ b/apps/elf-eval/src/bin/trace_regression_gate/main.rs @@ -2,13 +2,13 @@ //! CLI for evaluating trace-regression gates against stored traces. -#[path = "trace_regression_gate/cli.rs"] mod cli; -#[path = "trace_regression_gate/eval.rs"] mod eval; -#[path = "trace_regression_gate/gate.rs"] mod gate; -#[path = "trace_regression_gate/replay.rs"] mod replay; -#[path = "trace_regression_gate/reports.rs"] mod reports; -#[path = "trace_regression_gate/rows.rs"] mod rows; -#[path = "trace_regression_gate/storage.rs"] mod storage; +mod cli; +mod eval; +mod gate; +mod replay; +mod reports; +mod rows; +mod storage; use std::fs; diff --git a/packages/elf-service/src/list.rs b/packages/elf-service/src/list.rs index d1e94803..251785b9 100644 --- a/packages/elf-service/src/list.rs +++ b/packages/elf-service/src/list.rs @@ -1,71 +1,15 @@ //! Note listing APIs. -use std::collections::HashSet; +mod access_filter; +mod query; +mod request; +mod types; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sqlx::{PgPool, QueryBuilder}; -use time::OffsetDateTime; -use uuid::Uuid; - -use crate::{ - ElfService, Error, Result, - access::{self, ORG_PROJECT_ID}, -}; -use elf_storage::models::MemoryNote; - -/// Request payload for note listing. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct ListRequest { - /// Tenant to list notes from. - pub tenant_id: String, - /// Project to list notes from. - pub project_id: String, - /// Optional agent filter and required owner for `agent_private`. - pub agent_id: Option, - /// Optional scope filter. - pub scope: Option, - /// Optional lifecycle status filter. - pub status: Option, - /// Optional note-type filter. - pub r#type: Option, -} +pub use self::types::{ListItem, ListRequest, ListResponse}; -/// One note returned by `list`. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct ListItem { - /// Note identifier. - pub note_id: Uuid, - /// Note type discriminator. - pub r#type: String, - /// Optional application-defined key. - pub key: Option, - /// Scope key for the note. - pub scope: String, - /// Lifecycle status for the note. - pub status: String, - /// Note body text. - pub text: String, - /// Importance score. - pub importance: f32, - /// Confidence score. - pub confidence: f32, - #[serde(with = "crate::time_serde")] - /// Last update timestamp. - pub updated_at: OffsetDateTime, - #[serde(with = "crate::time_serde::option")] - /// Optional expiry timestamp. - pub expires_at: Option, - /// Structured source reference metadata. - pub source_ref: Value, -} +use time::OffsetDateTime; -/// Response payload for note listing. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct ListResponse { - /// Notes visible to the caller after access filtering. - pub items: Vec, -} +use crate::{ElfService, Result}; impl ElfService { /// Lists notes visible to the caller under the requested filters. @@ -74,7 +18,7 @@ impl ElfService { let tenant_id = req.tenant_id.trim(); let project_id = req.project_id.trim(); let agent_id = req.agent_id.as_ref().map(|value| value.trim()).unwrap_or(""); - let requested_status = requested_list_status(req.status.as_ref()); + let requested_status = request::requested_list_status(req.status.as_ref()); let status_for_note_read = requested_status.unwrap_or("active").eq_ignore_ascii_case("active"); let non_private_scopes = match req.scope.as_deref().map(str::trim) { @@ -85,15 +29,33 @@ impl ElfService { ), }; - validate_list_request(&req, tenant_id, project_id, agent_id, &self.cfg.scopes.allowed)?; + request::validate_list_request( + &req, + tenant_id, + project_id, + agent_id, + &self.cfg.scopes.allowed, + )?; - let shared_grants = - list_shared_grants(&self.db.pool, tenant_id, project_id, agent_id, &non_private_scopes) - .await?; - let notes = - list_notes(&self.db.pool, &req, tenant_id, project_id, requested_status, agent_id, now) - .await?; - let items = map_list_items( + let shared_grants = access_filter::list_shared_grants( + &self.db.pool, + tenant_id, + project_id, + agent_id, + &non_private_scopes, + ) + .await?; + let notes = query::list_notes( + &self.db.pool, + &req, + tenant_id, + project_id, + requested_status, + agent_id, + now, + ) + .await?; + let items = access_filter::map_list_items( notes, agent_id, non_private_scopes.as_deref(), @@ -105,176 +67,3 @@ impl ElfService { Ok(ListResponse { items }) } } - -fn requested_list_status(requested_status: Option<&String>) -> Option<&str> { - requested_status.map(|value| value.trim()).filter(|value| !value.is_empty()) -} - -fn validate_list_request( - req: &ListRequest, - tenant_id: &str, - project_id: &str, - agent_id: &str, - allowed_scopes: &[String], -) -> Result<()> { - if tenant_id.is_empty() || project_id.is_empty() { - return Err(Error::InvalidRequest { - message: "tenant_id and project_id are required.".to_string(), - }); - } - - if let Some(scope) = req.scope.as_ref() - && !allowed_scopes.iter().any(|value| value == scope) - { - return Err(Error::ScopeDenied { message: "Scope is not allowed.".to_string() }); - } - if let Some(agent_id) = req.agent_id.as_ref() - && agent_id.trim().is_empty() - { - return Err(Error::InvalidRequest { - message: "agent_id must not be empty when provided.".to_string(), - }); - } - - if req.scope.as_deref() == Some("agent_private") && agent_id.is_empty() { - return Err(Error::ScopeDenied { - message: "agent_id is required for agent_private scope.".to_string(), - }); - } - - Ok(()) -} - -fn map_list_items( - notes: Vec, - agent_id: &str, - non_private_scopes: Option<&[String]>, - shared_grants: &HashSet, - status_for_note_read: bool, - now: OffsetDateTime, -) -> Vec { - notes - .into_iter() - .filter(|note| { - let Some(scopes) = non_private_scopes else { - return true; - }; - - if status_for_note_read { - return access::note_read_allowed(note, agent_id, scopes, shared_grants, now); - } - - note.agent_id == agent_id - || shared_grants.contains(&crate::access::SharedSpaceGrantKey { - scope: note.scope.clone(), - space_owner_agent_id: note.agent_id.clone(), - }) - }) - .map(|note| ListItem { - note_id: note.note_id, - r#type: note.r#type, - key: note.key, - scope: note.scope, - status: note.status, - text: note.text, - importance: note.importance, - confidence: note.confidence, - updated_at: note.updated_at, - expires_at: note.expires_at, - source_ref: note.source_ref, - }) - .collect() -} - -async fn list_shared_grants( - pool: &PgPool, - tenant_id: &str, - project_id: &str, - agent_id: &str, - non_private_scopes: &Option>, -) -> Result> { - if non_private_scopes.is_none() || agent_id.is_empty() { - return Ok(HashSet::new()); - } - - let org_shared_allowed = - non_private_scopes.as_ref().is_some_and(|scopes| scopes.iter().any(|s| s == "org_shared")); - - access::load_shared_read_grants_with_org_shared( - pool, - tenant_id, - project_id, - agent_id, - org_shared_allowed, - ) - .await -} - -async fn list_notes( - pool: &PgPool, - req: &ListRequest, - tenant_id: &str, - project_id: &str, - requested_status: Option<&str>, - agent_id: &str, - now: OffsetDateTime, -) -> Result> { - let mut builder = QueryBuilder::new( - "SELECT note_id, tenant_id, project_id, agent_id, scope, type, key, text, importance, confidence, status, created_at, updated_at, expires_at, embedding_version, source_ref, hit_count, last_hit_at \ - FROM memory_notes WHERE tenant_id = ", - ); - - builder.push_bind(tenant_id); - - let include_org_shared = match req.scope.as_deref().map(str::trim) { - None => true, - Some("org_shared") => true, - Some(_) => false, - }; - - if include_org_shared { - builder.push(" AND (project_id = "); - builder.push_bind(project_id); - builder.push(" OR (project_id = "); - builder.push_bind(ORG_PROJECT_ID); - builder.push(" AND scope = "); - builder.push_bind("org_shared"); - builder.push("))"); - } else { - builder.push(" AND project_id = "); - builder.push_bind(project_id); - } - - if let Some(scope) = &req.scope { - builder.push(" AND scope = "); - builder.push_bind(scope); - - if scope == "agent_private" { - builder.push(" AND agent_id = "); - builder.push_bind(agent_id); - } - } else { - builder.push(" AND scope != "); - builder.push_bind("agent_private"); - } - if let Some(status) = requested_status { - builder.push(" AND status = "); - builder.push_bind(status); - } else { - builder.push(" AND status = "); - builder.push_bind("active"); - } - - if requested_status.unwrap_or("active").eq_ignore_ascii_case("active") { - builder.push(" AND (expires_at IS NULL OR expires_at > "); - builder.push_bind(now); - builder.push(")"); - } - - if let Some(note_type) = &req.r#type { - builder.push(" AND type = "); - builder.push_bind(note_type); - } - - builder.build_query_as().fetch_all(pool).await.map_err(Into::into) -} diff --git a/packages/elf-service/src/list/access_filter.rs b/packages/elf-service/src/list/access_filter.rs new file mode 100644 index 00000000..e74a2650 --- /dev/null +++ b/packages/elf-service/src/list/access_filter.rs @@ -0,0 +1,76 @@ +use std::collections::HashSet; + +use sqlx::PgPool; +use time::OffsetDateTime; + +use crate::{ + Result, + access::{self}, + list::ListItem, +}; +use elf_storage::models::MemoryNote; + +pub(super) fn map_list_items( + notes: Vec, + agent_id: &str, + non_private_scopes: Option<&[String]>, + shared_grants: &HashSet, + status_for_note_read: bool, + now: OffsetDateTime, +) -> Vec { + notes + .into_iter() + .filter(|note| { + let Some(scopes) = non_private_scopes else { + return true; + }; + + if status_for_note_read { + return access::note_read_allowed(note, agent_id, scopes, shared_grants, now); + } + + note.agent_id == agent_id + || shared_grants.contains(&crate::access::SharedSpaceGrantKey { + scope: note.scope.clone(), + space_owner_agent_id: note.agent_id.clone(), + }) + }) + .map(|note| ListItem { + note_id: note.note_id, + r#type: note.r#type, + key: note.key, + scope: note.scope, + status: note.status, + text: note.text, + importance: note.importance, + confidence: note.confidence, + updated_at: note.updated_at, + expires_at: note.expires_at, + source_ref: note.source_ref, + }) + .collect() +} + +pub(super) async fn list_shared_grants( + pool: &PgPool, + tenant_id: &str, + project_id: &str, + agent_id: &str, + non_private_scopes: &Option>, +) -> Result> { + if non_private_scopes.is_none() || agent_id.is_empty() { + return Ok(HashSet::new()); + } + + let org_shared_allowed = + non_private_scopes.as_ref().is_some_and(|scopes| scopes.iter().any(|s| s == "org_shared")); + + access::load_shared_read_grants_with_org_shared( + pool, + tenant_id, + project_id, + agent_id, + org_shared_allowed, + ) + .await +} diff --git a/packages/elf-service/src/list/query.rs b/packages/elf-service/src/list/query.rs new file mode 100644 index 00000000..47b6fbcb --- /dev/null +++ b/packages/elf-service/src/list/query.rs @@ -0,0 +1,74 @@ +use sqlx::{PgPool, QueryBuilder}; +use time::OffsetDateTime; + +use crate::{Result, access::ORG_PROJECT_ID, list::ListRequest}; +use elf_storage::models::MemoryNote; + +pub(super) async fn list_notes( + pool: &PgPool, + req: &ListRequest, + tenant_id: &str, + project_id: &str, + requested_status: Option<&str>, + agent_id: &str, + now: OffsetDateTime, +) -> Result> { + let mut builder = QueryBuilder::new( + "SELECT note_id, tenant_id, project_id, agent_id, scope, type, key, text, importance, confidence, status, created_at, updated_at, expires_at, embedding_version, source_ref, hit_count, last_hit_at \ + FROM memory_notes WHERE tenant_id = ", + ); + + builder.push_bind(tenant_id); + + let include_org_shared = match req.scope.as_deref().map(str::trim) { + None => true, + Some("org_shared") => true, + Some(_) => false, + }; + + if include_org_shared { + builder.push(" AND (project_id = "); + builder.push_bind(project_id); + builder.push(" OR (project_id = "); + builder.push_bind(ORG_PROJECT_ID); + builder.push(" AND scope = "); + builder.push_bind("org_shared"); + builder.push("))"); + } else { + builder.push(" AND project_id = "); + builder.push_bind(project_id); + } + + if let Some(scope) = &req.scope { + builder.push(" AND scope = "); + builder.push_bind(scope); + + if scope == "agent_private" { + builder.push(" AND agent_id = "); + builder.push_bind(agent_id); + } + } else { + builder.push(" AND scope != "); + builder.push_bind("agent_private"); + } + if let Some(status) = requested_status { + builder.push(" AND status = "); + builder.push_bind(status); + } else { + builder.push(" AND status = "); + builder.push_bind("active"); + } + + if requested_status.unwrap_or("active").eq_ignore_ascii_case("active") { + builder.push(" AND (expires_at IS NULL OR expires_at > "); + builder.push_bind(now); + builder.push(")"); + } + + if let Some(note_type) = &req.r#type { + builder.push(" AND type = "); + builder.push_bind(note_type); + } + + builder.build_query_as().fetch_all(pool).await.map_err(Into::into) +} diff --git a/packages/elf-service/src/list/request.rs b/packages/elf-service/src/list/request.rs new file mode 100644 index 00000000..c4eeeb99 --- /dev/null +++ b/packages/elf-service/src/list/request.rs @@ -0,0 +1,40 @@ +use crate::{Error, Result, list::ListRequest}; + +pub(super) fn requested_list_status(requested_status: Option<&String>) -> Option<&str> { + requested_status.map(|value| value.trim()).filter(|value| !value.is_empty()) +} + +pub(super) fn validate_list_request( + req: &ListRequest, + tenant_id: &str, + project_id: &str, + agent_id: &str, + allowed_scopes: &[String], +) -> Result<()> { + if tenant_id.is_empty() || project_id.is_empty() { + return Err(Error::InvalidRequest { + message: "tenant_id and project_id are required.".to_string(), + }); + } + + if let Some(scope) = req.scope.as_ref() + && !allowed_scopes.iter().any(|value| value == scope) + { + return Err(Error::ScopeDenied { message: "Scope is not allowed.".to_string() }); + } + if let Some(agent_id) = req.agent_id.as_ref() + && agent_id.trim().is_empty() + { + return Err(Error::InvalidRequest { + message: "agent_id must not be empty when provided.".to_string(), + }); + } + + if req.scope.as_deref() == Some("agent_private") && agent_id.is_empty() { + return Err(Error::ScopeDenied { + message: "agent_id is required for agent_private scope.".to_string(), + }); + } + + Ok(()) +} diff --git a/packages/elf-service/src/list/types.rs b/packages/elf-service/src/list/types.rs new file mode 100644 index 00000000..a7b269b2 --- /dev/null +++ b/packages/elf-service/src/list/types.rs @@ -0,0 +1,57 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use time::OffsetDateTime; +use uuid::Uuid; + +/// Request payload for note listing. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ListRequest { + /// Tenant to list notes from. + pub tenant_id: String, + /// Project to list notes from. + pub project_id: String, + /// Optional agent filter and required owner for `agent_private`. + pub agent_id: Option, + /// Optional scope filter. + pub scope: Option, + /// Optional lifecycle status filter. + pub status: Option, + /// Optional note-type filter. + pub r#type: Option, +} + +/// One note returned by `list`. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ListItem { + /// Note identifier. + pub note_id: Uuid, + /// Note type discriminator. + pub r#type: String, + /// Optional application-defined key. + pub key: Option, + /// Scope key for the note. + pub scope: String, + /// Lifecycle status for the note. + pub status: String, + /// Note body text. + pub text: String, + /// Importance score. + pub importance: f32, + /// Confidence score. + pub confidence: f32, + #[serde(with = "crate::time_serde")] + /// Last update timestamp. + pub updated_at: OffsetDateTime, + #[serde(with = "crate::time_serde::option")] + /// Optional expiry timestamp. + pub expires_at: Option, + /// Structured source reference metadata. + pub source_ref: Value, +} + +/// Response payload for note listing. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ListResponse { + /// Notes visible to the caller after access filtering. + pub items: Vec, +} diff --git a/packages/elf-service/src/structured_fields/validation.rs b/packages/elf-service/src/structured_fields/validation.rs index 57dcc9e8..4d8f7728 100644 --- a/packages/elf-service/src/structured_fields/validation.rs +++ b/packages/elf-service/src/structured_fields/validation.rs @@ -1,22 +1,18 @@ -use serde::Deserialize; +mod bounds; +mod entity; +mod quotes; +mod relation; +mod text; + use serde_json::Value; use crate::{ Error, Result, - structured_fields::types::{StructuredEntity, StructuredFields, StructuredRelation}, + structured_fields::{ + types::StructuredFields, + validation::bounds::{MAX_ENTITIES, MAX_RELATIONS}, + }, }; -use elf_domain::{english_gate, evidence}; - -const MAX_LIST_ITEMS: usize = 64; -const MAX_ENTITIES: usize = 32; -const MAX_RELATIONS: usize = 64; -const MAX_ALIASES: usize = 16; -const MAX_ITEM_CHARS: usize = 1_000; - -#[derive(Clone, Debug, Deserialize)] -struct SourceRefEvidenceQuote { - quote: String, -} /// Validates structured fields against note text, evidence bindings, and size limits. pub fn validate_structured_fields( @@ -28,26 +24,26 @@ pub fn validate_structured_fields( let evidence_quotes: Vec = if let Some(event_evidence) = add_event_evidence { event_evidence.iter().map(|(_, quote)| quote.clone()).collect() } else { - extract_source_ref_quotes(source_ref) + quotes::extract_source_ref_quotes(source_ref) }; if let Some(summary) = structured.summary.as_ref() { - validate_text_field(summary, "structured.summary")?; + text::validate_text_field(summary, "structured.summary")?; } if let Some(entities) = structured.entities.as_ref() { - validate_list_field_count(entities.len(), MAX_ENTITIES, "structured.entities")?; + bounds::validate_list_field_count(entities.len(), MAX_ENTITIES, "structured.entities")?; for (idx, entity) in entities.iter().enumerate() { let base = format!("structured.entities[{idx}]"); - validate_structured_entity(entity, &base, true)?; + entity::validate_structured_entity(entity, &base, true)?; } } if let Some(relations) = structured.relations.as_ref() { - validate_list_field_count(relations.len(), MAX_RELATIONS, "structured.relations")?; + bounds::validate_list_field_count(relations.len(), MAX_RELATIONS, "structured.relations")?; for (idx, relation) in relations.iter().enumerate() { - validate_structured_relation( + relation::validate_structured_relation( relation, note_text, &evidence_quotes, @@ -56,12 +52,12 @@ pub fn validate_structured_fields( } } if let Some(facts) = structured.facts.as_ref() { - validate_list_field(facts, "structured.facts")?; + bounds::validate_list_field(facts, "structured.facts")?; for (idx, fact) in facts.iter().enumerate() { - validate_text_field(fact, &format!("structured.facts[{idx}]"))?; + text::validate_text_field(fact, &format!("structured.facts[{idx}]"))?; - if !fact_is_evidence_bound(fact, note_text, &evidence_quotes) { + if !quotes::fact_is_evidence_bound(fact, note_text, &evidence_quotes) { return Err(Error::InvalidRequest { message: format!( "structured.facts[{idx}] is not supported by note text or evidence quotes." @@ -71,10 +67,10 @@ pub fn validate_structured_fields( } } if let Some(concepts) = structured.concepts.as_ref() { - validate_list_field(concepts, "structured.concepts")?; + bounds::validate_list_field(concepts, "structured.concepts")?; for (idx, concept) in concepts.iter().enumerate() { - validate_text_field(concept, &format!("structured.concepts[{idx}]"))?; + text::validate_text_field(concept, &format!("structured.concepts[{idx}]"))?; } } @@ -83,205 +79,5 @@ pub fn validate_structured_fields( /// Validates event-evidence quotes against their source messages. pub fn event_evidence_quotes(messages: &[String], evidence: &[(usize, String)]) -> Result<()> { - for (idx, (message_index, quote)) in evidence.iter().enumerate() { - if quote.trim().is_empty() { - return Err(Error::InvalidRequest { - message: format!("evidence[{idx}].quote must not be empty."), - }); - } - if !evidence::evidence_matches(messages, *message_index, quote) { - return Err(Error::InvalidRequest { - message: format!("evidence[{idx}] does not match its source message."), - }); - } - } - - Ok(()) -} - -fn validate_structured_entity( - entity: &StructuredEntity, - base: &str, - require_canonical: bool, -) -> Result<()> { - if require_canonical { - validate_required_text_field(entity.canonical.as_ref(), &format!("{base}.canonical"))?; - } - - if let Some(kind) = entity.kind.as_ref() { - validate_text_field(kind, &format!("{base}.kind"))?; - } - if let Some(aliases) = entity.aliases.as_ref() { - validate_list_field_count(aliases.len(), MAX_ALIASES, &format!("{base}.aliases"))?; - - for (alias_idx, alias) in aliases.iter().enumerate() { - validate_text_field(alias, &format!("{base}.aliases[{alias_idx}]"))?; - } - } - - Ok(()) -} - -fn validate_structured_relation( - relation: &StructuredRelation, - note_text: &str, - evidence_quotes: &[String], - base: &str, -) -> Result<()> { - if relation.predicate.is_none() { - return Err(Error::InvalidRequest { message: format!("{base}.predicate is required.") }); - } - - let subject = relation - .subject - .as_ref() - .ok_or_else(|| Error::InvalidRequest { message: format!("{base}.subject is required.") })?; - - validate_structured_entity(subject, &format!("{base}.subject"), true)?; - - let predicate = relation.predicate.as_ref().ok_or_else(|| Error::InvalidRequest { - message: format!("{base}.predicate is required."), - })?; - - validate_text_field(predicate, &format!("{base}.predicate"))?; - - let object = relation - .object - .as_ref() - .ok_or_else(|| Error::InvalidRequest { message: format!("{base}.object is required.") })?; - - match (&object.entity, object.value.as_ref()) { - (Some(entity), None) => { - validate_structured_entity(entity, &format!("{base}.object.entity"), true)?; - - let canonical = entity.canonical.as_deref().ok_or_else(|| Error::InvalidRequest { - message: format!("{base}.object.entity.canonical is required."), - })?; - - if !fact_is_evidence_bound(canonical, note_text, evidence_quotes) { - return Err(Error::InvalidRequest { - message: format!( - "{base}.object.entity.canonical is not supported by note text or evidence quotes." - ), - }); - } - }, - (None, Some(value)) => { - validate_text_field(value, &format!("{base}.object.value"))?; - - if !fact_is_evidence_bound(value, note_text, evidence_quotes) { - return Err(Error::InvalidRequest { - message: format!( - "{base}.object.value is not supported by note text or evidence quotes." - ), - }); - } - }, - (_, _) => { - return Err(Error::InvalidRequest { - message: format!("{base}.object must provide exactly one of entity or value."), - }); - }, - } - - if !fact_is_evidence_bound( - subject.canonical.as_deref().unwrap_or_default(), - note_text, - evidence_quotes, - ) { - return Err(Error::InvalidRequest { - message: format!( - "{base}.subject.canonical is not supported by note text or evidence quotes." - ), - }); - } - if !fact_is_evidence_bound(predicate, note_text, evidence_quotes) { - return Err(Error::InvalidRequest { - message: format!("{base}.predicate is not supported by note text or evidence quotes."), - }); - } - - if let (Some(valid_from), Some(valid_to)) = (relation.valid_from, relation.valid_to) - && valid_to <= valid_from - { - return Err(Error::InvalidRequest { - message: format!("{base}.valid_to must be greater than valid_from."), - }); - } - - Ok(()) -} - -fn validate_list_field(items: &[String], label: &str) -> Result<()> { - if items.len() > MAX_LIST_ITEMS { - return Err(Error::InvalidRequest { - message: format!("{label} must have at most {MAX_LIST_ITEMS} items."), - }); - } - - Ok(()) -} - -fn validate_text_field(value: &str, label: &str) -> Result<()> { - let trimmed = value.trim(); - - if trimmed.is_empty() { - return Err(Error::InvalidRequest { message: format!("{label} must not be empty.") }); - } - if trimmed.chars().count() > MAX_ITEM_CHARS { - return Err(Error::InvalidRequest { - message: format!("{label} must be at most {MAX_ITEM_CHARS} characters."), - }); - } - if !english_gate::is_english_natural_language(trimmed) { - return Err(Error::NonEnglishInput { field: label.to_string() }); - } - - Ok(()) -} - -fn validate_required_text_field(value: Option<&String>, label: &str) -> Result<()> { - let Some(value) = value else { - return Err(Error::InvalidRequest { message: format!("{label} is required.") }); - }; - - validate_text_field(value, label) -} - -fn validate_list_field_count(len: usize, max: usize, label: &str) -> Result<()> { - if len > max { - return Err(Error::InvalidRequest { - message: format!("{label} must have at most {max} items."), - }); - } - - Ok(()) -} - -fn extract_source_ref_quotes(source_ref: &Value) -> Vec { - let Some(evidence) = source_ref.get("evidence") else { return Vec::new() }; - let Ok(quotes) = serde_json::from_value::>(evidence.clone()) else { - return Vec::new(); - }; - - quotes.into_iter().map(|q| q.quote).collect() -} - -fn fact_is_evidence_bound(fact: &str, note_text: &str, evidence_quotes: &[String]) -> bool { - let trimmed = fact.trim(); - - if trimmed.is_empty() { - return false; - } - if note_text.contains(trimmed) { - return true; - } - - for quote in evidence_quotes { - if quote.contains(trimmed) { - return true; - } - } - - false + quotes::event_evidence_quotes(messages, evidence) } diff --git a/packages/elf-service/src/structured_fields/validation/bounds.rs b/packages/elf-service/src/structured_fields/validation/bounds.rs new file mode 100644 index 00000000..699a5ec5 --- /dev/null +++ b/packages/elf-service/src/structured_fields/validation/bounds.rs @@ -0,0 +1,26 @@ +use crate::{Error, Result}; + +pub(super) const MAX_LIST_ITEMS: usize = 64; +pub(super) const MAX_ENTITIES: usize = 32; +pub(super) const MAX_RELATIONS: usize = 64; +pub(super) const MAX_ALIASES: usize = 16; + +pub(super) fn validate_list_field(items: &[String], label: &str) -> Result<()> { + if items.len() > MAX_LIST_ITEMS { + return Err(Error::InvalidRequest { + message: format!("{label} must have at most {MAX_LIST_ITEMS} items."), + }); + } + + Ok(()) +} + +pub(super) fn validate_list_field_count(len: usize, max: usize, label: &str) -> Result<()> { + if len > max { + return Err(Error::InvalidRequest { + message: format!("{label} must have at most {max} items."), + }); + } + + Ok(()) +} diff --git a/packages/elf-service/src/structured_fields/validation/entity.rs b/packages/elf-service/src/structured_fields/validation/entity.rs new file mode 100644 index 00000000..5dce8da7 --- /dev/null +++ b/packages/elf-service/src/structured_fields/validation/entity.rs @@ -0,0 +1,36 @@ +use crate::{ + Result, + structured_fields::{ + types::StructuredEntity, + validation::{ + bounds::{self, MAX_ALIASES}, + text, + }, + }, +}; + +pub(super) fn validate_structured_entity( + entity: &StructuredEntity, + base: &str, + require_canonical: bool, +) -> Result<()> { + if require_canonical { + text::validate_required_text_field( + entity.canonical.as_ref(), + &format!("{base}.canonical"), + )?; + } + + if let Some(kind) = entity.kind.as_ref() { + text::validate_text_field(kind, &format!("{base}.kind"))?; + } + if let Some(aliases) = entity.aliases.as_ref() { + bounds::validate_list_field_count(aliases.len(), MAX_ALIASES, &format!("{base}.aliases"))?; + + for (alias_idx, alias) in aliases.iter().enumerate() { + text::validate_text_field(alias, &format!("{base}.aliases[{alias_idx}]"))?; + } + } + + Ok(()) +} diff --git a/packages/elf-service/src/structured_fields/validation/quotes.rs b/packages/elf-service/src/structured_fields/validation/quotes.rs new file mode 100644 index 00000000..e528eeaf --- /dev/null +++ b/packages/elf-service/src/structured_fields/validation/quotes.rs @@ -0,0 +1,62 @@ +use serde::Deserialize; +use serde_json::Value; + +use crate::{Error, Result}; +use elf_domain::evidence; + +#[derive(Clone, Debug, Deserialize)] +struct SourceRefEvidenceQuote { + quote: String, +} + +pub(super) fn event_evidence_quotes( + messages: &[String], + evidence: &[(usize, String)], +) -> Result<()> { + for (idx, (message_index, quote)) in evidence.iter().enumerate() { + if quote.trim().is_empty() { + return Err(Error::InvalidRequest { + message: format!("evidence[{idx}].quote must not be empty."), + }); + } + if !evidence::evidence_matches(messages, *message_index, quote) { + return Err(Error::InvalidRequest { + message: format!("evidence[{idx}] does not match its source message."), + }); + } + } + + Ok(()) +} + +pub(super) fn extract_source_ref_quotes(source_ref: &Value) -> Vec { + let Some(evidence) = source_ref.get("evidence") else { return Vec::new() }; + let Ok(quotes) = serde_json::from_value::>(evidence.clone()) else { + return Vec::new(); + }; + + quotes.into_iter().map(|q| q.quote).collect() +} + +pub(super) fn fact_is_evidence_bound( + fact: &str, + note_text: &str, + evidence_quotes: &[String], +) -> bool { + let trimmed = fact.trim(); + + if trimmed.is_empty() { + return false; + } + if note_text.contains(trimmed) { + return true; + } + + for quote in evidence_quotes { + if quote.contains(trimmed) { + return true; + } + } + + false +} diff --git a/packages/elf-service/src/structured_fields/validation/relation.rs b/packages/elf-service/src/structured_fields/validation/relation.rs new file mode 100644 index 00000000..91761ce1 --- /dev/null +++ b/packages/elf-service/src/structured_fields/validation/relation.rs @@ -0,0 +1,97 @@ +use crate::{ + Error, Result, + structured_fields::{ + types::StructuredRelation, + validation::{entity, quotes, text}, + }, +}; + +pub(super) fn validate_structured_relation( + relation: &StructuredRelation, + note_text: &str, + evidence_quotes: &[String], + base: &str, +) -> Result<()> { + if relation.predicate.is_none() { + return Err(Error::InvalidRequest { message: format!("{base}.predicate is required.") }); + } + + let subject = relation + .subject + .as_ref() + .ok_or_else(|| Error::InvalidRequest { message: format!("{base}.subject is required.") })?; + + entity::validate_structured_entity(subject, &format!("{base}.subject"), true)?; + + let predicate = relation.predicate.as_ref().ok_or_else(|| Error::InvalidRequest { + message: format!("{base}.predicate is required."), + })?; + + text::validate_text_field(predicate, &format!("{base}.predicate"))?; + + let object = relation + .object + .as_ref() + .ok_or_else(|| Error::InvalidRequest { message: format!("{base}.object is required.") })?; + + match (&object.entity, object.value.as_ref()) { + (Some(entity), None) => { + entity::validate_structured_entity(entity, &format!("{base}.object.entity"), true)?; + + let canonical = entity.canonical.as_deref().ok_or_else(|| Error::InvalidRequest { + message: format!("{base}.object.entity.canonical is required."), + })?; + + if !quotes::fact_is_evidence_bound(canonical, note_text, evidence_quotes) { + return Err(Error::InvalidRequest { + message: format!( + "{base}.object.entity.canonical is not supported by note text or evidence quotes." + ), + }); + } + }, + (None, Some(value)) => { + text::validate_text_field(value, &format!("{base}.object.value"))?; + + if !quotes::fact_is_evidence_bound(value, note_text, evidence_quotes) { + return Err(Error::InvalidRequest { + message: format!( + "{base}.object.value is not supported by note text or evidence quotes." + ), + }); + } + }, + (_, _) => { + return Err(Error::InvalidRequest { + message: format!("{base}.object must provide exactly one of entity or value."), + }); + }, + } + + if !quotes::fact_is_evidence_bound( + subject.canonical.as_deref().unwrap_or_default(), + note_text, + evidence_quotes, + ) { + return Err(Error::InvalidRequest { + message: format!( + "{base}.subject.canonical is not supported by note text or evidence quotes." + ), + }); + } + if !quotes::fact_is_evidence_bound(predicate, note_text, evidence_quotes) { + return Err(Error::InvalidRequest { + message: format!("{base}.predicate is not supported by note text or evidence quotes."), + }); + } + + if let (Some(valid_from), Some(valid_to)) = (relation.valid_from, relation.valid_to) + && valid_to <= valid_from + { + return Err(Error::InvalidRequest { + message: format!("{base}.valid_to must be greater than valid_from."), + }); + } + + Ok(()) +} diff --git a/packages/elf-service/src/structured_fields/validation/text.rs b/packages/elf-service/src/structured_fields/validation/text.rs new file mode 100644 index 00000000..c89358e1 --- /dev/null +++ b/packages/elf-service/src/structured_fields/validation/text.rs @@ -0,0 +1,30 @@ +use crate::{Error, Result}; +use elf_domain::english_gate; + +const MAX_ITEM_CHARS: usize = 1_000; + +pub(super) fn validate_text_field(value: &str, label: &str) -> Result<()> { + let trimmed = value.trim(); + + if trimmed.is_empty() { + return Err(Error::InvalidRequest { message: format!("{label} must not be empty.") }); + } + if trimmed.chars().count() > MAX_ITEM_CHARS { + return Err(Error::InvalidRequest { + message: format!("{label} must be at most {MAX_ITEM_CHARS} characters."), + }); + } + if !english_gate::is_english_natural_language(trimmed) { + return Err(Error::NonEnglishInput { field: label.to_string() }); + } + + Ok(()) +} + +pub(super) fn validate_required_text_field(value: Option<&String>, label: &str) -> Result<()> { + let Some(value) = value else { + return Err(Error::InvalidRequest { message: format!("{label} is required.") }); + }; + + validate_text_field(value, label) +}