@@ -11,6 +11,7 @@ pub mod federation;
1111pub mod graphql;
1212pub mod grpc;
1313pub mod rbac;
14+ pub mod regenerator;
1415pub mod transaction;
1516pub mod vql;
1617
@@ -36,6 +37,9 @@ use verisim_drift::{DriftDetector, DriftMetrics, DriftThresholds, DriftType};
3637use verisim_graph:: RedbGraphStore ;
3738#[ cfg( not( feature = "persistent" ) ) ]
3839use verisim_graph:: SimpleGraphStore ;
40+ use verisim_normalizer:: regeneration:: {
41+ Modality as RegenModality , RegenerationConfig , RegenerationEngine , RegenerationResult ,
42+ } ;
3943use verisim_normalizer:: { create_default_normalizer, Normalizer , NormalizerStatus } ;
4044use 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) ) ]
12551322async 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