canwu-sim 0.13.1

Deterministic simulation runtime, scheduling, persistence, replay, and plugins for Canwu
Documentation
use super::*;

const TICKET_ID: DecisionTicketId = DecisionTicketId::new(1);

fn random_decision_stream() -> RandomStreamKey {
    RandomStreamKey::new("fixture-random-decision", "decision-selection", 1)
}

fn resolve_ticket_randomly(
    view: &SimulationView<'_>,
    context: &BoundaryContext,
) -> Result<BoundaryProposal, CanwuError> {
    let ticket = view.decision_ticket(TICKET_ID)?.ok_or_else(|| {
        CanwuError::new(
            ErrorCode::InvalidDecision,
            "random decision fixture ticket is missing",
        )
    })?;
    let evidence = context.admitted_ingress.first().copied().ok_or_else(|| {
        CanwuError::new(
            ErrorCode::EvidenceUnavailable,
            "random decision fixture requires admitted calendar evidence",
        )
    })?;
    let option_weights = vec![
        DecisionOptionWeight::new("fail", 1),
        DecisionOptionWeight::new("pass", 3),
    ];
    let sample = view.random_sample_for_operation(
        &random_decision_stream(),
        EvidenceRef::Ingress(evidence),
        "decision_selection",
        "fixture-law-vote-1",
        RandomOperationTarget::DecisionTicket {
            ticket_id: ticket.id,
            ticket_version: ticket.version,
        },
        0,
        4,
        "select one available decision option by configured weight",
    )?;
    Ok(BoundaryProposal {
        directives: vec![BoundaryDirective::ResolveDecisionRandomly {
            resolution: RandomDecisionResolution {
                priority: 0,
                decision_request_id: DecisionRequestId::new(3),
                command_request_id: None,
                ticket_id: ticket.id,
                expected_version: ticket.version,
                controller_id: ticket.assigned_controller.clone(),
                sample,
                option_weights,
                tie_break: None,
            },
        }],
        ..BoundaryProposal::default()
    })
}

struct RandomDecisionPlugin;

impl SimulationPlugin for RandomDecisionPlugin {
    fn name(&self) -> &'static str {
        "fixture-random-decision"
    }

    fn version(&self) -> &'static str {
        "1.0.0"
    }

    fn semantic_hash(&self) -> &'static str {
        "c1ed435888aeebc0fe3dc476fc7ea2b2343f728bf41ef752e6f4f0a93bf79735"
    }

    fn register(&self, registrar: &mut PluginRegistrar<'_>) -> Result<(), CanwuError> {
        let mut contract = BoundarySystemContract::new(
            "resolve",
            BoundaryPhase::StrategicAggregation,
            SystemCadence::Daily,
        );
        contract.reads = vec![StateKey::core_decisions()];
        contract.random_streams = vec![random_decision_stream()];
        registrar.register_boundary_system(contract, resolve_ticket_randomly)
    }
}

fn rewrite_rejected_random_weights(
    snapshot: &mut SimulationSnapshot,
    rewrite: impl FnOnce(&str, &mut Vec<DecisionOptionWeight>),
) {
    let request_commitment = {
        let request = snapshot
            .ingress
            .iter_mut()
            .find_map(|record| match &mut record.payload {
                IngressPayload::Decision { request }
                    if request.request_id == DecisionRequestId::new(3) =>
                {
                    Some(request.as_mut())
                }
                _ => None,
            })
            .expect("generated random decision ingress should remain in evidence");
        let DecisionMutation::Resolve { decision, .. } = &mut request.mutation else {
            panic!("generated random decision ingress should resolve its ticket");
        };
        let DecisionOutcome::Selected { option_id } = &decision.outcome else {
            panic!("generated random decision should select an option");
        };
        let option_id = option_id.clone();
        let weights = &mut decision
            .random
            .as_mut()
            .expect("generated decision should retain random evidence")
            .option_weights;
        rewrite(&option_id, weights);
        canonical_hash(DECISION_REQUEST_COMMITMENT_DOMAIN, request)
            .expect("tampered request should still hash")
    };
    let mut decisions =
        serde_json::to_value(&snapshot.decisions).expect("decision state should serialize");
    let attempts = decisions
        .get_mut("attempts")
        .and_then(|attempts| attempts.get_mut("entries"))
        .and_then(serde_json::Value::as_object_mut)
        .expect("decision attempts should use the persistent log wire shape");
    let attempt = attempts
        .values_mut()
        .find(|attempt| attempt.get("request_id") == Some(&serde_json::json!(3)))
        .expect("rejected random decision attempt should be persisted");
    attempt["request_commitment"] = serde_json::Value::String(request_commitment);
    snapshot.decisions = serde_json::from_value(decisions)
        .expect("tampered decision state should remain structurally decodable");
    rehash_tampered_snapshot(snapshot);
}

#[test]
fn random_policy_resolution_is_generated_replayable_and_tamper_evident() {
    let (scenario, ids) = demo_scenario();
    let plugin = RandomDecisionPlugin;
    let mut simulation = Simulation::new(202, scenario).expect("demo should load");
    simulation
        .register_plugin(&plugin)
        .expect("random decision plugin should register");
    let controller = DecisionControllerBinding::new(
        "fixture-random-controller",
        DecisionPolicyIdentity::new(DecisionPolicyKind::Random, "weighted-random", "1"),
        DecisionAuthority::Actor {
            actor: ids.commander,
        },
    );
    simulation
        .enqueue_decision(
            SimTime::EPOCH,
            0,
            DecisionIngressRequest::new(
                DecisionRequestId::new(1),
                0,
                DecisionMutation::RegisterController { controller },
            ),
        )
        .expect("controller should queue");
    simulation
        .enqueue_decision(
            SimTime::EPOCH,
            0,
            DecisionIngressRequest::new(
                DecisionRequestId::new(2),
                0,
                DecisionMutation::Open {
                    ticket: DecisionTicketDraft {
                        id: TICKET_ID,
                        definition: "fixture.law-vote".to_owned(),
                        decision_maker: EntityRef::Person(ids.commander),
                        assigned_controller: "fixture-random-controller".to_owned(),
                        summary: "Decide whether the law passes".to_owned(),
                        context: DecisionContext::new(
                            "fixture.law-vote.v1",
                            serde_json::json!({"support": 75, "opposition": 25}),
                        ),
                        options: vec![
                            DecisionOption::new("pass", "Pass"),
                            DecisionOption::new("fail", "Fail"),
                        ],
                        deadline: None,
                        parent_ticket: None,
                    },
                },
            ),
        )
        .expect("ticket should queue");
    simulation
        .settle_boundary(BoundaryRequest::at(SimTime::EPOCH))
        .expect("decision intake should settle");

    let selection_at = SimTime::EPOCH + SimDuration::days(1);
    simulation
        .schedule_calendar_boundary(selection_at, vec![SystemCadence::Daily])
        .expect("calendar boundary should queue");
    let source = simulation
        .step_canonical()
        .expect("source boundary should run")
        .expect("source boundary receipt");
    assert_eq!(source.random_draws.len(), 1);
    assert_eq!(source.generated_ingress.len(), 1);
    assert!(
        simulation
            .decision_ticket(TICKET_ID)
            .is_some_and(DecisionTicket::is_open)
    );

    let mut rejected = simulation.fork();
    rejected
        .enqueue_command(
            selection_at,
            -1,
            CommandRequest::new(
                CommandRequestId::new(1),
                rejected.revision(),
                move_order(&ids).at_time(selection_at),
            ),
        )
        .expect("interfering command ingress should queue");
    for _ in 0..2 {
        if rejected
            .decision_attempt(DecisionRequestId::new(3))
            .is_some()
        {
            break;
        }
        rejected
            .step_canonical()
            .expect("interfering boundary should settle")
            .expect("interfering boundary receipt");
    }
    let rejected_outcome = rejected
        .decision_attempt(DecisionRequestId::new(3))
        .map(|attempt| &attempt.outcome);
    assert!(
        matches!(
            rejected_outcome,
            Some(DecisionAttemptOutcome::Rejected {
                code: DecisionAttemptErrorCode::SimulationRevisionConflict,
                ..
            })
        ),
        "unexpected generated random decision outcome: {rejected_outcome:?}"
    );
    let mut tampered_rejection = rejected.snapshot();
    rewrite_rejected_random_weights(&mut tampered_rejection, |option_id, weights| {
        for weight in weights {
            weight.weight = u64::from(weight.option_id != option_id) * 4;
        }
    });
    let Err(error) = Simulation::from_snapshot_with_plugins(tampered_rejection, &[&plugin]) else {
        panic!("remapped rejected random weights must be rejected");
    };
    assert_eq!(error.code, ErrorCode::InvalidSnapshot);

    let mut incomplete_rejection = rejected.snapshot();
    rewrite_rejected_random_weights(&mut incomplete_rejection, |option_id, weights| {
        weights.retain(|weight| weight.option_id == option_id);
        weights[0].weight = 4;
    });
    let Err(error) = Simulation::from_snapshot_with_plugins(incomplete_rejection, &[&plugin])
    else {
        panic!("incomplete rejected random weights must be rejected");
    };
    assert_eq!(error.code, ErrorCode::InvalidSnapshot);

    simulation
        .step_canonical()
        .expect("generated decision boundary should run")
        .expect("generated decision boundary receipt");
    let trace = simulation
        .decision_trace(DecisionTraceId::new(1))
        .expect("random decision trace should exist");
    let random = trace.random.as_ref().expect("trace should cite its draw");
    assert_eq!(random.draw_id, source.random_draws[0]);
    assert_eq!(random.option_weights[0].option_id, "fail");
    let draw = simulation
        .random_draws()
        .iter()
        .find(|draw| draw.id == random.draw_id)
        .expect("cited draw should be retained");
    assert!(matches!(
        &draw.outcome,
        Some(RandomDrawOutcome::DecisionSelection {
            ticket_id,
            ticket_version: 1,
            option_id,
        }) if *ticket_id == TICKET_ID
            && matches!(&trace.outcome, DecisionOutcome::Selected { option_id: selected } if selected == option_id)
    ));

    let restored = Simulation::from_snapshot_with_plugins(simulation.snapshot(), &[&plugin])
        .expect("random decision evidence should restore");
    assert_eq!(restored.snapshot(), simulation.snapshot());
    let replayed = Simulation::replay_from_journal(&[&plugin], &simulation.replay_journal())
        .expect("random decision evidence should replay exactly");
    assert_eq!(replayed.snapshot(), simulation.snapshot());

    let mut tampered = simulation.snapshot();
    let draw = tampered
        .random_draws
        .first_mut()
        .expect("tamper fixture should contain random draw evidence");
    draw.value = (draw.value + 1) % draw.upper_exclusive;
    let Err(error) = Simulation::from_snapshot_with_plugins(tampered, &[&plugin]) else {
        panic!("tampered random decision evidence must be rejected");
    };
    assert_eq!(error.code, ErrorCode::InvalidSnapshot);
}