Repository navigation
Rust Rewrite - #375
Draft
sandorstormen wants to merge 67 commits into
Draft
Rust Rewrite#375sandorstormen wants to merge 67 commits into
sandorstormen wants to merge 67 commits into
Conversation
… supported with rust backend
Replace hardcoded log tuples tied to a specific RNG seed with forced detector tprate/fprate extremes (1.0/0.0/-1.0) and invariant-based assertions, so the tests no longer break if simulator internals change how/when rng draws happen. Also fixes detector_lang.mal's detector annotation syntax (fnr -> tpr) to match the current mal-toolbox grammar. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A dyna-MAL model effect can delete the asset performing the step (e.g. `R> sub*.files / self` with no `~` marker, which deliberately means "destroy self", not just unlink an association). Every attack step on that asset, including the step itself, is then removed from the live attack graph. mal-toolbox's core always keeps a *live* node's children/parents correctly unlinked from anything removed, but a node that is itself dead only exposes a frozen pre-removal `.children` snapshot (kept readable so held references, e.g. in `performed_nodes`, don't crash). get_attack_surface/get_effects_of_attack_step were reading that stale snapshot as if it were live, letting an already-removed node leak back into a later action surface and crash several steps after the fact with an unrelated-looking KeyError. Skip expanding `.children` from any node that is no longer live - the single point where a stale snapshot could be consulted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ove unused imports
Relocates the malsim package to python/malsim so it sits alongside new Rust code, mirroring mal-toolbox's rust-rewrite layout. Adds a core/ Cargo workspace (malsim-core, depending on mal-toolbox's maltoolbox-attackgraph crate on the rust-rewrite branch) and a separate py-bindings/ Cargo workspace (malsim-pyo3) for PyO3 bindings between the Rust core and the Python package. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Documents the phased plan (MalSimulator, then DynaMalSimulator, then a Rust-only library/scenario-loading path) for moving the hot step()/reset() loop into core/malsim-core + py-bindings/malsim-pyo3, while keeping AttackerState/DefenderState/Scenario/config objects as unchanged, hand-written Python classes. Records the architectural decisions made along the way (RNG reproducibility, shared-graph strategy with mal-toolbox's PyO3 layer, NodePropertyRule/reward boundary, Rust code style and terminology) and a running differences log to track Rust-vs- Python divergences as they're found. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…l-toolbox PyO3 modules Switches pyproject.toml to maturin and wires malsim-pyo3 to mal-toolbox's rust-rewrite branch (pinned to commit c854d1d6) to validate PORTING_NOTES.md §2.2 end to end. The originally-specified mechanism (downcasting a Python maltoolbox.AttackGraph back to mal-toolbox's PyAttackGraph pyclass from inside a separately-compiled cdylib) doesn't work - PyO3 pyclasses from a shared dependency crate get an unrelated type object in every cdylib that statically links it. Fixed via a PyCapsule handoff (PyAttackGraph's new __inner_capsule__() upstream, consumed here), which also drops malsim-pyo3's dependency on maltoolbox-attackgraph-py entirely. Also fixes a double-free in the capsule consumer found via a create/call/delete/gc.collect() stress test (needed Rc::increment_strong_count before Rc::from_raw). See PORTING_NOTES.md §10 for the full writeup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ports ttc_utils.py's DistFunction/Operation/TTCDist (expected_value, sample_value, success_probability, attempt_ttc_with_effort, attempt_bernoulli, from_dict/to_dict) to core/malsim-core/src/ttc.rs, backed by the statrs crate for CDF/mean/sampling. Deliberately excludes the graph-node-dependent default_ttc_dist/from_node/from_name, scoped to Phase A3 instead, so this module stays independent of maltoolbox_attackgraph. 26 Rust-native tests cover every DistFunction variant's expected_value (exact, since it's closed-form) plus the combine_with/combine_op composition suite ported 1:1 from test_ttc_utils.py, and structural/statistical (not seed-pinned) checks for the RNG-touching methods per PORTING_NOTES.md's RNG-equivalence policy. PORTING_NOTES.md updated: A2 marked done, a seed-pinned-test follow-up noted for A9, and a differences-log entry for the statrs parameter-order gotchas (Binomial, Gamma) and the deliberately-preserved success_probability/combine_with behavior. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ports graph_state.py and the graph-node-dependent half of ttc_utils.py (default_ttc_dist, TTCDist.from_node, attack_step_ttc_value(s), get_pre_enabled_defenses, get_impossible_attack_steps, compute_initial_graph_state) into core/malsim-core/src/graph_state.rs, and graph_processing.py's necessity propagation (evaluate_necessity, _propagate_necessity_from_node, calculate_necessity) into core/malsim-core/src/necessity.rs. Viability is deliberately not ported - confirmed dead/deprecated code with no callers outside its own module. Each graph-dependent function is split into a thin AttackGraphNode- reading wrapper plus a graph-independent helper over plain data, so the actual logic stays unit-testable even without a real attack graph fixture (building one needs a dev-dependency on maltoolbox-language, deferred per discussion - see PORTING_NOTES.md §10). 41 Rust tests passing; cargo clippy/fmt clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ports node_is_blocked/node_is_traversable/node_is_live/is_attack_step from graph_utils.py into core/malsim-core/src/graph_utils.rs (node_is_actionable/node_reward stay Python-only per §2.4). Adds maltoolbox-language as a dev-dependency-only crate dependency so tests can build real AttackGraphNode fixtures (core/malsim-core/src/ test_fixtures.rs, mirroring tests/conftest.py::dummy_lang_graph) - also backfills necessity.rs's previously-deferred tests from A3 in the same change. 59 Rust-native tests total, cargo clippy/fmt clean; full Python suite still green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…lsim-core) Ports get_attack_surface, get_effects_of_attack_step (attack_surface.py) and get_defense_surface (defense_surface.py). Actionability is taken as an already-flattened id-set rather than a NodePropertyRule, per the porting plan. 14 new Rust-native tests; cargo test/clippy/fmt and the full Python suite stay green.
…core)
Ports false_alerts.py/observability.py/event_logger.py into
core/malsim-core/src/{false_alerts,observability,event_logger}.rs: rate
rules are taken as already-flattened id maps/sets per §2.4 (same pattern
as A5), and LogEntry's detector/trigger fields become id-based since
mal-toolbox's Detector has no id of its own.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…alsim-core) Composes A2-A6's modules into the actual per-agent step logic: attacker_step/ attempt_attacker_step/attacker_step_effects/attacker_is_terminated and defender_step/defender_is_terminated, with 22 new Rust-native tests. See PORTING_NOTES.md §0/§10 for scope and the preserved-as-is behavioral quirks this phase ported faithfully.
…ive) Composes A1-A7's ported malsim-core modules into a full reset/step loop exposed to Python as plain dicts, proving they work together end to end before A9 wires MalSimulator itself to the native backend. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116xUQqCURws4Hk4w4cBdKo
MalSimulator now holds one malsim._native.Simulator instance and rebuilds AttackerState/DefenderState/MalSimulatorState from its reset_native()/step_native() output via new native-driven factory functions, added alongside (not replacing) the pure-Python ones DynaMalSimulator still depends on. Extends the native layer to close A8's remaining scope cuts: per-agent ttc_dists overrides are now parsed and applied in reset_native, and reset_native/step_native expose the full GraphState (ttc_values, impossible_attack_steps, necessity_per_node, pre_enabled_defenses) so Python can reconstruct MalSimulatorState without recomputing anything. Fixes two bugs surfaced by this phase's first real end-to-end runs: - A pre-existing collect_logs bug (checked model_asset even for nodes with no detectors; Python never did). - A genuine cross-process non-determinism bug: Rust's default HashSet hasher is randomized per process, so action_surface/other id lists crossing the FFI boundary came back in a different order on every run even for an identical seed - stable_ids now sorts. Also documents and works around two mal-toolbox PyO3 binding quirks (node.children/.parents and node.detectors are Python-side caches that .add()/item-assignment never sync back to the real graph) that broke ~35 test call sites and 3 detector-rate tests once native started reading the graph directly instead of through Python. Full details, including the DynaMalSimulator-coupling rationale for the new-functions-alongside-old-ones design and every relaxed/re-pinned seed-dependent test, are in PORTING_NOTES.md §0/§9/§10. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116xUQqCURws4Hk4w4cBdKo
Adds a rust.yml workflow (fmt + build, matrixed over the root and py-bindings cargo workspaces) and switches the PyPI publish workflow to maturin-action's multi-platform wheel matrix with TestPyPI gating PyPI, matching mal-lang/mal-toolbox#255. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116xUQqCURws4Hk4w4cBdKo
The linux x86_64 "Test wheel" step reinstalls mal-simulator from the freshly built wheel, which pulls in mal-toolbox from git and compiles it from source on the host. maturin-action's prior sccache-enabled build step leaves RUSTC_WRAPPER=sccache in the job environment, but that binary only exists inside the manylinux container, so the host build failed with "could not execute process `sccache ... rustc -vV`". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116xUQqCURws4Hk4w4cBdKo
Confirms all 19 audited public query methods already satisfy the "native-backed vs. pure-Python mirror" split A9 established, with zero code changes needed - verified via git diff across the A8->A9 boundary. Also flags a new drift risk (§9) for node_is_blocked/node_is_traversable's duplicate Python/Rust implementations and clarifies graph_utils.py/ state_query.py/node_getters.py are not A11 deletion candidates. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116xUQqCURws4Hk4w4cBdKo
…ated state Profiling showed the native rewrite was 2.6x slower than pre-port pure Python (111.6s vs 42.7s for 5000 steps): step_native resent the entire episode-accumulated state over the FFI boundary every call even though it had already computed the per-step delta and discarded it, and both Python state factories re-resolved/re-parsed the full value from scratch every step instead of merging against previous_state. The defender logs field was the worst case - O(episode^2), since every log ever fired was re-parsed into a fresh LogEntry on every single step. step_native's output is now delta-only for monotonically-growing fields (step_enabled_defenses/step_performed_nodes/step_attempted_nodes/ step_compromised_nodes/step_observed_nodes/step_logs); episode-static fields (ttc_values, necessity_per_node, impossible_attack_steps, pre_enabled_defenses) are dropped from step_native's output and cached from reset_native instead. Both *_state_factories.py now merge deltas against previous_state. reset_native's output shape is unchanged. Re-profiling confirms the fix: a 5000-step run accumulating 10,002 logs (worst case for the old O(episode^2) behavior) completes in ~1s with flat per-step cost. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Break §6 into B1-B7: 2 pure-Rust-logic steps in malsim-core (compressed from Phase A's 7, since mal-toolbox's rust-rewrite branch already ports the hard primitives - model mutation and the model-effect grammar) plus 5 Python-bindings-integration steps mirroring Phase A's A1/A8-A11 granularity, including a new B3 proving step for a shared Model handle that Phase A never needed (PyModel has no __inner_capsule__ yet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116xUQqCURws4Hk4w4cBdKo
… step orchestration to malsim-core B1 ports association-traversal evaluation + model-effect application (process_assoc_traversal.py/model_effects.py) to assoc_traversal.rs/ model_effects.rs, operating on i64 asset ids throughout rather than object references. B2 composes B1 with Phase A's existing attacker_step/defender_step/graph_state functions into the full dyna step orchestration (dyna_graph_state.rs/dyna_attacker_step.rs/ dyna_defender_step.rs) plus model-snapshot reconciliation for reset (model_state.rs). Promotes maltoolbox-model and maltoolbox-language to direct malsim-core dependencies. 23 new Rust-native tests (149 total), reusing wiperLang.mal/ wiper_model.yml fixtures already used by the Python test suite. No Python files touched; full Python/mypy/ruff gate stays green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116xUQqCURws4Hk4w4cBdKo
Mirrors A1's PyAttackGraph capsule approach for maltoolbox.Model. Upstream mal-toolbox gained PyModel::__inner_capsule__ (commit b96258bad474282b975245d9848f7c25d195d508 on rust-rewrite); this repo re-pins to that rev, adds maltoolbox-model as a direct malsim-pyo3 dependency, and wires up extract_shared_model plus two test-support pyfunctions (model_asset_count, model_add_asset_native) that prove mutations are visible from both the Python and native sides - the one thing A1's own smoke test never needed to prove, since Phase A never mutates the shared graph from both sides at once. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116xUQqCURws4Hk4w4cBdKo
B4 (done): extends the existing malsim._native.Simulator pyclass with
dyna_reset_native/dyna_step_native (reusing AttackerRuntime/DefenderRuntime/
SimState and the output-building helpers rather than a separate pyclass),
threading B3's shared Model handle alongside A1's shared AttackGraph handle.
The pristine model snapshot is captured natively on first use instead of
being passed in from Python. malsim-core's AssetOp/AssocOp now carry a
self-contained AssetRef (id+type+name) per asset reference, captured at
record time, so a modification record never needs a live Model lookup to
resolve - needed because a later op in the same record can invalidate an
id an earlier op referenced, and native-driven asset removal bypasses
maltoolbox's PyO3-level tombstone mechanism entirely.
B5 (partial, blocked): DynaMalSimulator.reset()/.step() are rewired to
delegate to native, mirroring Phase A9's reset()/step() exactly. Blocked
on a real regression: native dyna stepping can panic the whole process
("invalid SlotMap key used") when a model effect's association churn
disconnects an asset, since several Phase-A-era malsim-core functions
(get_attack_surface, attempt_attacker_step, necessity::calculate_necessity,
node_is_blocked/node_is_traversable, collect_logs) do unguarded node-id
indexing that assumed the graph never mutates mid-episode - an assumption
Phase B's whole design breaks. Full findings, a repro, and three
undecided remediation options are recorded in PORTING_NOTES.md's B5
entry and in section 9's open risks for whoever resumes this phase.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0116xUQqCURws4Hk4w4cBdKo
…TTC gap Hands-on eprintln instrumentation (not just backtrace reading) found the B5 panic's real mechanism differs from the original PORTING_NOTES writeup: a step's own model effect can remove the step's own backing asset outright (no regeneration, no new id), not "regenerate a disconnected asset's node under a new id" as previously diagnosed - this is the second time this failure class was misdiagnosed, now called out explicitly in the notes. Guard every unguarded graph.nodes[id] indexing on accumulated/historical ids found via a full audit of both crates: collect_logs (the original crash site), dyna_attacker_step's effect-chain loop, and - not in the original 5-site list at all - malsim-pyo3::simulator.rs's stable_ids/ id_value_map/attacker_ttc_overrides/log_entry_to_py, where the second domino crash actually was. test_different_attackers: 0/12 -> 10/12. Remaining 2/12 (TTCSoftMinAttacker) fail on a different, newly-exposed bug - newly-created dyna nodes missing a TTC value - confirmed as a genuine Rust-port regression via comparison against pure-Python DynaMalSimulator, documented with a repro for whoever resumes B5. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116xUQqCURws4Hk4w4cBdKo
…the model The dyna native step was treating ttc_values/necessity_per_node/ impossible_attack_steps/pre_enabled_defenses as episode-static (true for Phase A, false once model effects grow them mid-episode), so a node created by a dyna model effect never got its ttc value mirrored back to Python. build_step_output now resends the full current maps, but only on a step whose step_modification_record is non-empty - correctness over a per-field delta, since necessity_per_node is a whole-graph recompute, not just new keys. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116xUQqCURws4Hk4w4cBdKo
A long-standing typo (dot became a comma) left this as a non-.py file. It never broke anything until maturin packaged it into a wheel RECORD, where the unescaped comma corrupted the CSV and broke `uv sync` for any project depending on mal-simulator via git. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116xUQqCURws4Hk4w4cBdKo
…ngs module conventions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjG65UvNdYrV9uGiqo8nXa
…im-core New settings module (MalSimulatorSettings, AttackSurfaceSettings, RewardMode, Flat*Settings simulator inputs); scenario::agent_settings (AttackerSettings<N>/DefenderSettings/agent_settings_from_dict, policy kept opaque); scenario::flatten (get_entry_points + flatten_*_settings mirroring native_settings.py's per-node semantics). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjG65UvNdYrV9uGiqo8nXa
…-Rust graph construction
Scenario::{new, from_dict, load_from_file} loads the language and model
(file or inline), builds the AttackGraph, resolves attacker entry
points/goals, and exposes flatten_agents() producing Simulator::reset
input. Ports test_scenario.py's scenario-level cases.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjG65UvNdYrV9uGiqo8nXa
…m-pyo3 into malsim-core New public malsim_core::simulator::Simulator (new/new_dyna/reset/step/ state) with SimState, Attacker/DefenderRuntime, StepOutcome and SimulatorError. malsim-pyo3 is now a thin dict-parsing/output-building wrapper with an unchanged Python-facing API. Agents are iterated in name order (deterministic) instead of HashMap order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjG65UvNdYrV9uGiqo8nXa
…Scenario -> Simulator -> steps) Integration test in malsim-core using only the public API: loads real fixtures, builds a Simulator (plain and dyna), resets via Scenario::flatten_agents and steps with hand-picked actions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjG65UvNdYrV9uGiqo8nXa
…n JSON tests/scenario_parity.py generates each fixture's resolved shape from the Python Scenario into tests/testdata/scenario_parity.json; tests/test_scenario_parity.py keeps it current, and core/malsim-core/tests/scenario_parity.rs checks the Rust loader against it. Fixes the gap it found: YAML !!set entry points/goals. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjG65UvNdYrV9uGiqo8nXa
…'s graph DynaMalSimulator.reset flattened per-node rules (actionable/observable steps, FP/FN rates, TTC overrides) before native restored the model, so nodes removed by the previous episode's model effects and regenerated by the restore were missing from the new episode's settings. Adds the additive core Simulator::restore_model() (no-op for plain simulators and pristine models; reset still restores), exposed as pyo3's dyna_restore_model_native(model). dyna_reset now restores first, then flattens. Regression tests in Python and Rust (both fail without the fix); Scenario::flatten_agents documents the restore-first requirement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjG65UvNdYrV9uGiqo8nXa
…restore Entry points/goals were node objects (Python) / node ids (Rust) resolved once at scenario load. When an episode's model effects removed their asset, the restore regenerated those nodes with new ids: the next Python dyna reset raised 'node id N is not part of this simulator's attack graph', and the Rust-only path silently dropped the entry point. DynaMalSimulator now captures full-name attacker settings at construction and dyna_reset re-resolves them after dyna_restore_model_native, returning the refreshed agent settings. The Rust Scenario keeps a private full-name copy and flatten_agents re-resolves it on every call (now returns Result). Regression tests in Python and Rust for single and multiple entry-point sets; both fail without the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjG65UvNdYrV9uGiqo8nXa
Split malsim-core's single Simulator (new_dyna + optional model handle) into two types: Simulator runs a static instance model/attack graph, and the new DynaSimulator wraps a Simulator plus the shared Model and its snapshot, reusing the Simulator's reset and per-agent bookkeeping and swapping in the model-effect-aware step functions (composition, the Rust analogue of DynaMalSimulator(MalSimulator)). restore_model moves to DynaSimulator, and StepOutcome drops the modification record, which only DynaStepOutcome carries. malsim-pyo3 exposes the same split as two pyclasses: _native.Simulator(graph) and _native.DynaSimulator(graph, model), both with reset_native/step_native (plus restore_model_native on the dyna one), sharing the dict conversions. The dyna_*_native methods are gone. DynaMalSimulator builds a DynaSimulator in __init__ and keeps it as _dyna_native_sim. Record the user's decisions in PORTING_NOTES.md §11 (superseding the single-Simulator convention) and the judgment calls in §12. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017CNWy9A9w7qn4m6YfR1nDY
Contributor
Author
|
Current speed up in
|
The port is complete, so drop the per-step history, forward-looking phase plans, resolved risks and debugging narratives. Keep the design decisions, conventions, open risks and the Rust/Python differences log. Section numbers are unchanged because code comments cite them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…them Type checkers only resolve a submodule's stubs from a same-named .pyi next to it, so malsim/_native-stubs/ was never read and malsim._native resolved to Unknown in pyright/Pylance. Also add py.typed and bump mal-toolbox to the commit carrying the same fix for maltoolbox._native. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up to 815dad6, which only captured the stub move itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rust-only counterparts of python/malsim/policies, rewards.py and run_simulation.py: a DecisionAgent trait over borrowed AgentState views, the nine built-in policies, per-step rewards and the reference simulation loop. The Python path is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW2RLEBahsSroo5KDXLWxE
Rust counterpart of python/malsim/__main__.py: loads a scenario, applies -s/-t, optionally dumps the attack graph (-o) and runs run_simulation. Uses clap for argument parsing. There is no -g: the GUI client is not ported. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW2RLEBahsSroo5KDXLWxE
…NG_NOTES Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW2RLEBahsSroo5KDXLWxE
Profile-driven (downstream darpa-mapper run: ~39 s -> ~14.5 s, output unchanged modulo serialization order). P0: - get_attack_surface: single pass with one dedup set; drop the per-step performed_nodes clone in update_attacker_runtimes. - necessity: pre-size the map, iterative propagation worklist. - Skip fold_new_nodes_into_graph_state when model effects produced no ops and no new nodes (provably a no-op); extend GraphState in place. - Match AttackStepType instead of step_type.as_str() everywhere; public helpers take the enum. Remove now-unreachable NecessityError and ViabilityError UnknownStepType variants and the unused add_new_nodes_to_graph_state wrapper. P1: - NodeSet/NodeMap (FxHash, rustc-hash) for AttackGraphNodeId-keyed collections; NodeDenseMap (slotmap::SecondaryMap) for necessity_per_node. - Deterministic iteration at every RNG-consuming / order-producing site so the same seed reproduces the same run; renumber nodes created by partially_regenerate_graph canonically (workaround until mal-toolbox creates them in a deterministic order). - AttackerRuntime/AttackerState::performed_history records the order nodes were performed in. - tests/reproducibility.rs runs scenarios twice in-process per seed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ibility Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Port of Python's Scenario.save_to_file. Writes sim_settings, reward_mode and ttc_overrides (as predefined names) in addition to what Python emits, so saved scenarios round-trip through load_from_file. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mal-toolbox now hashes AttackGraphNode.children/parents with FxHash, so the model_effects test helper takes &NodeSet instead of a std HashSet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SimState gains a private EpisodeHistory (reached through history()) that records what each step added, the action-surface diffs, the dyna modification record and a per-step recording (requested actions first). SimState::snapshot_at(k) rebuilds the full state at any earlier iteration of the episode by undoing steps from the current state. This is what lets the Python simulators hold no state of their own. The existing public API (Simulator/DynaSimulator, SimState fields, AttackerRuntime/DefenderRuntime, StepOutcome, SimulatorError) is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016JwvGg2gyyCePXkbLabrxQ
Simulator and DynaSimulator gain agent_states_native, sim_state_native, recording_native, terminated_native and episode_native. Each translates the current native state into plain dicts when called. An Episode handle reads any earlier iteration of its episode; when reset starts a new episode while handles are alive, the old one is archived as an EpisodeRecord (state + history without the RNG) for them. reset_native/step_native are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016JwvGg2gyyCePXkbLabrxQ
sandorstormen
force-pushed
the
rust-rewrite
branch
from
October 23, 2026 13:19
afd14dc to
c53f8ac
Compare
Add update_attack_surface, which patches an existing surface after a step by re-checking only the new performed nodes, their children, and the children of newly enabled defenses, instead of rebuilding it with get_attack_surface. update_attacker_runtimes uses it, and falls back to a full rebuild for dyna-MAL steps with a model effect and before the first step when entry points were not compromised at start. Debug builds assert the result equals a full rebuild after every step. Unit tests and two sweeps in tests/incremental_attack_surface.rs check parity with get_attack_surface across all test scenarios and settings. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Attackers loaded from a scenario dict without a reward_mode now get EXPECTED_TTC instead of CUMULATIVE, in both the Rust and Python loaders. Defenders keep CUMULATIVE. The AttackerSettings dataclass default is unchanged. EXPECTED_TTC only sums the nodes performed in the current step, so it avoids sorting every performed node on each step. Pin CUMULATIVE on the attacker of bfs_vs_bfs_network_app_data_scenario.yml, which test_bfs_vs_bfs_state_and_reward_first expects to have reward 0, and regenerate tests/testdata/scenario_parity.json. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A dyna step whose model effects ran used to store a full clone of graph_state and enabled_defenses in the step history, so snapshot_at could restore it. Each step now stores a GraphStateDelta: the nodes its folds created and the old necessity values they changed. snapshot_at removes the new nodes' keys and restores the old necessity values, removing keys before inserting since the necessity SecondaryMap ignores an older key while a newer one holds the slot. dyna_attacker_step and dyna_defender_step return the delta as the last element of their result tuple. Node keys are versioned slotmap keys, so a regenerated node never reuses an earlier key; a unit test checks that slots are reused but keys are not. On a 2000-host model, 3000 steps (~1720 with model effects), memory growth drops from 271.2 +- 1.3 MiB to 11.5 +- 0.1 MiB (n=10, 95% CI), with no change in run time. Add dyna_defense_lang.mal and dyna_defense_scenario.yml, whose model effects add and remove assets that have defenses, and make dyna_snapshots_match_live_state assert that slot reuse, necessity changes on surviving nodes, fold-created pre-enabled defenses and impossible steps, and action-enabled defenses all occur. Regenerate tests/testdata/scenario_parity.json for the new scenario. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.