Skip to content

Commit ed9f268

Browse files
claudehyperpolymath
authored andcommitted
feat(normalizer): real store-backed regeneration + working normalize endpoint
- verisim-api regenerator module: StoreRegenerator implements the normalizer's ModalityRegenerator against the live octad store. Supported repairs (all real write-backs through OctadStore::update, so WAL-logged, versioned, and recorded on the provenance chain as 'normalized' events): Graph -> Document (rebuild relations section from actual edges) Document -> Vector (deterministic feature-hashing embedding) Document/Vector -> Tensor (1-D feature tensor) Unsupported pairs return honest errors instead of fake success. measure_drift re-reads persisted state and scores via drift_compute. - AppState gains a RegenerationEngine wired to StoreRegenerator, with a content-modality authority order (provenance/temporal excluded as regeneration sources: audit records, not content). - POST /normalizer/trigger/{id} replaces the 202 no-op: measures drift, picks the worst repairable component, runs the engine, re-measures from persisted state, feeds the aggregates, and returns action/before/after + the full post-repair component report. - Tests: hash-embedding determinism, relations-section idempotency, and an end-to-end integration test (drift 0.5 -> repaired -> 0.0, provenance chain extended, second call no_action_needed). Workspace: 620 passed / 0 failed; clippy clean under -D warnings. Closes Week-1 items 3-4 of docs/reviews/2026-06-11-deep-review-and-july-1-plan.adoc https://claude.ai/code/session_01E8BpV19yxhf67UrCrvkfTr
1 parent b11cc9e commit ed9f268

4 files changed

Lines changed: 602 additions & 8 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rust-core/verisim-api/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ verisim-planner = { path = "../verisim-planner" }
2525

2626
axum.workspace = true
2727
tokio.workspace = true
28+
async-trait.workspace = true
2829
tower.workspace = true
2930
hyper.workspace = true
3031
serde.workspace = true

rust-core/verisim-api/src/lib.rs

Lines changed: 237 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub mod federation;
1111
pub mod graphql;
1212
pub mod grpc;
1313
pub mod rbac;
14+
pub mod regenerator;
1415
pub mod transaction;
1516
pub mod vql;
1617

@@ -36,6 +37,9 @@ use verisim_drift::{DriftDetector, DriftMetrics, DriftThresholds, DriftType};
3637
use verisim_graph::RedbGraphStore;
3738
#[cfg(not(feature = "persistent"))]
3839
use verisim_graph::SimpleGraphStore;
40+
use verisim_normalizer::regeneration::{
41+
Modality as RegenModality, RegenerationConfig, RegenerationEngine, RegenerationResult,
42+
};
3943
use verisim_normalizer::{create_default_normalizer, Normalizer, NormalizerStatus};
4044
use verisim_octad::{
4145
BoundingBox, Coordinates, InMemoryOctadStore, OctadConfig, OctadDocumentInput, OctadGraphInput,
@@ -501,6 +505,7 @@ pub struct AppState {
501505
pub octad_store: Arc<ConcreteOctadStore>,
502506
pub drift_detector: Arc<DriftDetector>,
503507
pub normalizer: Arc<Normalizer>,
508+
pub regeneration_engine: Arc<RegenerationEngine>,
504509
pub planner: Arc<Mutex<Planner>>,
505510
pub plan_cache: Arc<PlanCache>,
506511
pub slow_query_log: Arc<SlowQueryLog>,
@@ -666,6 +671,27 @@ impl AppState {
666671

667672
let drift_detector = Arc::new(DriftDetector::new(DriftThresholds::default()));
668673
let normalizer = Arc::new(create_default_normalizer(drift_detector.clone()).await);
674+
// Authority order restricted to content modalities: provenance and
675+
// temporal are audit/history records, not content a modality can be
676+
// regenerated FROM (under the default order, the ever-present
677+
// provenance chain would win source selection for every repair).
678+
let regeneration_engine = Arc::new(RegenerationEngine::with_regenerator(
679+
RegenerationConfig {
680+
authority_order: vec![
681+
RegenModality::Document,
682+
RegenModality::Semantic,
683+
RegenModality::Graph,
684+
RegenModality::Vector,
685+
RegenModality::Tensor,
686+
RegenModality::Spatial,
687+
],
688+
..Default::default()
689+
},
690+
Arc::new(regenerator::StoreRegenerator::new(
691+
octad_store.clone(),
692+
config.vector_dimension,
693+
)),
694+
));
669695

670696
let planner = Arc::new(Mutex::new(Planner::new(PlannerConfig::default())));
671697
let plan_cache = Arc::new(PlanCache::new(CacheConfig::default()));
@@ -688,6 +714,7 @@ impl AppState {
688714
octad_store,
689715
drift_detector,
690716
normalizer,
717+
regeneration_engine,
691718
planner,
692719
plan_cache,
693720
slow_query_log,
@@ -1250,28 +1277,141 @@ async fn normalizer_status_handler(
12501277
Ok(Json(status))
12511278
}
12521279

1253-
/// Trigger normalization handler
1280+
/// Outcome of a normalization request: measured drift before/after plus
1281+
/// what the regeneration engine actually did.
1282+
#[derive(Debug, Serialize, Deserialize)]
1283+
pub struct NormalizeResponse {
1284+
pub entity_id: String,
1285+
/// "repaired" | "no_action_needed" | "pending_resolution" | "failed"
1286+
pub action: String,
1287+
/// The modality that was regenerated, when a repair was attempted.
1288+
pub modality: Option<String>,
1289+
/// Source modality used for the repair, when known.
1290+
pub source_modality: Option<String>,
1291+
pub reason: Option<String>,
1292+
pub before_score: f64,
1293+
pub after_score: Option<f64>,
1294+
/// Full per-component drift report measured after the action.
1295+
pub report_after: drift_compute::EntityDriftReport,
1296+
}
1297+
1298+
/// Modality targeted for repair, derived from the worst measured component.
1299+
fn repair_target(report: &drift_compute::EntityDriftReport) -> Option<(RegenModality, f64)> {
1300+
report
1301+
.components
1302+
.iter()
1303+
.filter(|c| c.computable && c.score > 0.0)
1304+
.filter_map(|c| {
1305+
// Only components with an implemented regeneration path map to
1306+
// a repair target; the rest are detect-only for now.
1307+
let modality = match c.drift_type.as_str() {
1308+
"graph_document_drift" => Some(RegenModality::Document),
1309+
"tensor_drift" => Some(RegenModality::Tensor),
1310+
"semantic_vector_drift" => Some(RegenModality::Vector),
1311+
_ => None,
1312+
};
1313+
modality.map(|m| (m, c.score))
1314+
})
1315+
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
1316+
}
1317+
1318+
/// Trigger normalization: measure the entity's drift, pick the worst
1319+
/// repairable component, run the regeneration engine (which writes the
1320+
/// repair back through the store), and report measured before/after scores.
12541321
#[instrument(skip(state))]
12551322
async fn trigger_normalization_handler(
12561323
State(state): State<AppState>,
12571324
Path(id): Path<String>,
1258-
) -> Result<StatusCode, ApiError> {
1325+
) -> Result<(StatusCode, Json<NormalizeResponse>), ApiError> {
12591326
validate_octad_id(&id)?;
12601327
let octad_id = OctadId::new(&id);
12611328

1262-
// Check if octad exists
1263-
let _octad = state
1329+
let octad = state
12641330
.octad_store
12651331
.get(&octad_id)
12661332
.await
12671333
.map_err(|e| ApiError::Internal(e.to_string()))?
12681334
.ok_or_else(|| ApiError::NotFound(format!("Octad {} not found", id)))?;
12691335

1270-
// In a full implementation, this would trigger actual normalization
1271-
// For now, we just verify the octad exists and return accepted
1272-
info!(id = %id, "Normalization triggered for octad");
1336+
let before = drift_compute::compute_entity_drift(&state.octad_store, &octad, None).await;
12731337

1274-
Ok(StatusCode::ACCEPTED)
1338+
let Some((target, score)) = repair_target(&before) else {
1339+
return Ok((
1340+
StatusCode::OK,
1341+
Json(NormalizeResponse {
1342+
entity_id: id,
1343+
action: "no_action_needed".to_string(),
1344+
modality: None,
1345+
source_modality: None,
1346+
reason: Some("no repairable drift component above zero".to_string()),
1347+
before_score: before.overall_score,
1348+
after_score: None,
1349+
report_after: before,
1350+
}),
1351+
));
1352+
};
1353+
1354+
info!(id = %id, modality = %target, score, "Normalization triggered for octad");
1355+
let result = state
1356+
.regeneration_engine
1357+
.regenerate(&octad, target, score)
1358+
.await;
1359+
1360+
// Re-measure from the persisted state and feed the aggregates, so
1361+
// /drift/status reflects the post-repair reality.
1362+
let fresh = state
1363+
.octad_store
1364+
.get(&octad_id)
1365+
.await
1366+
.map_err(|e| ApiError::Internal(e.to_string()))?
1367+
.ok_or_else(|| ApiError::NotFound(format!("Octad {} not found", id)))?;
1368+
let after = drift_compute::compute_entity_drift(&state.octad_store, &fresh, None).await;
1369+
drift_compute::record_entity_drift(&state.drift_detector, &after).await;
1370+
1371+
let response = match result {
1372+
RegenerationResult::Repaired { event } => NormalizeResponse {
1373+
entity_id: id,
1374+
action: "repaired".to_string(),
1375+
modality: Some(event.drifted_modality.to_string()),
1376+
source_modality: event.source_modality.map(|m| m.to_string()),
1377+
reason: None,
1378+
before_score: score,
1379+
after_score: event.post_drift_score,
1380+
report_after: after,
1381+
},
1382+
RegenerationResult::NoActionNeeded => NormalizeResponse {
1383+
entity_id: id,
1384+
action: "no_action_needed".to_string(),
1385+
modality: Some(target.to_string()),
1386+
source_modality: None,
1387+
reason: Some("drift score at or below the engine threshold".to_string()),
1388+
before_score: score,
1389+
after_score: None,
1390+
report_after: after,
1391+
},
1392+
RegenerationResult::PendingResolution { reason, .. } => NormalizeResponse {
1393+
entity_id: id,
1394+
action: "pending_resolution".to_string(),
1395+
modality: Some(target.to_string()),
1396+
source_modality: None,
1397+
reason: Some(reason),
1398+
before_score: score,
1399+
after_score: None,
1400+
report_after: after,
1401+
},
1402+
RegenerationResult::Failed { error } => NormalizeResponse {
1403+
entity_id: id,
1404+
action: "failed".to_string(),
1405+
modality: Some(target.to_string()),
1406+
source_modality: None,
1407+
reason: Some(error),
1408+
before_score: score,
1409+
after_score: None,
1410+
report_after: after,
1411+
},
1412+
};
1413+
1414+
Ok((StatusCode::OK, Json(response)))
12751415
}
12761416

12771417
// --- Query Planner Handlers ---
@@ -2746,4 +2886,93 @@ mod tests {
27462886
);
27472887
assert_eq!(after, 0.0);
27482888
}
2889+
2890+
/// The full loop: measured drift -> normalize -> regeneration engine
2891+
/// repairs the document from the graph -> re-measured drift falls to
2892+
/// zero -> the repair is recorded in the provenance chain.
2893+
#[tokio::test]
2894+
async fn test_normalize_repairs_dissonant_entity() {
2895+
let state = create_test_state().await;
2896+
let app = build_router(state);
2897+
2898+
let created = post_octad(&app, &dissonant_octad_request()).await;
2899+
assert_eq!(created.provenance_chain_length, 1);
2900+
2901+
let response = app
2902+
.clone()
2903+
.oneshot(
2904+
Request::builder()
2905+
.method("POST")
2906+
.uri(format!("/normalizer/trigger/{}", created.id))
2907+
.body(Body::empty())
2908+
.unwrap(),
2909+
)
2910+
.await
2911+
.unwrap();
2912+
assert_eq!(response.status(), StatusCode::OK);
2913+
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
2914+
.await
2915+
.unwrap();
2916+
let normalized: NormalizeResponse = serde_json::from_slice(&body).unwrap();
2917+
2918+
assert_eq!(
2919+
normalized.action, "repaired",
2920+
"reason: {:?}",
2921+
normalized.reason
2922+
);
2923+
assert_eq!(normalized.modality.as_deref(), Some("document"));
2924+
assert_eq!(normalized.source_modality.as_deref(), Some("graph"));
2925+
assert!(normalized.before_score > 0.0);
2926+
assert_eq!(
2927+
normalized.after_score,
2928+
Some(0.0),
2929+
"regenerated document must fully restore graph-document consonance"
2930+
);
2931+
let graph_doc_after = normalized
2932+
.report_after
2933+
.components
2934+
.iter()
2935+
.find(|c| c.drift_type == "graph_document_drift")
2936+
.expect("graph_document_drift component missing");
2937+
assert_eq!(graph_doc_after.score, 0.0);
2938+
2939+
// The repair itself must be on the provenance chain.
2940+
let response = app
2941+
.clone()
2942+
.oneshot(
2943+
Request::builder()
2944+
.uri(format!("/octads/{}", created.id))
2945+
.body(Body::empty())
2946+
.unwrap(),
2947+
)
2948+
.await
2949+
.unwrap();
2950+
assert_eq!(response.status(), StatusCode::OK);
2951+
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
2952+
.await
2953+
.unwrap();
2954+
let fetched: OctadResponse = serde_json::from_slice(&body).unwrap();
2955+
assert_eq!(
2956+
fetched.provenance_chain_length, 2,
2957+
"the normalized event must extend the provenance chain"
2958+
);
2959+
2960+
// A consonant entity has nothing to repair.
2961+
let response = app
2962+
.oneshot(
2963+
Request::builder()
2964+
.method("POST")
2965+
.uri(format!("/normalizer/trigger/{}", created.id))
2966+
.body(Body::empty())
2967+
.unwrap(),
2968+
)
2969+
.await
2970+
.unwrap();
2971+
assert_eq!(response.status(), StatusCode::OK);
2972+
let body = axum::body::to_bytes(response.into_body(), 1024 * 1024)
2973+
.await
2974+
.unwrap();
2975+
let second: NormalizeResponse = serde_json::from_slice(&body).unwrap();
2976+
assert_eq!(second.action, "no_action_needed");
2977+
}
27492978
}

0 commit comments

Comments
 (0)