|
| 1 | +# ADR-001: Deterministic Simulation Testing Architecture for Motoko |
| 2 | + |
| 3 | +Date: 2026-06-27 |
| 4 | +Status: Proposed |
| 5 | + |
| 6 | +## Context |
| 7 | + |
| 8 | +Motoko failures increasingly occur across boundaries rather than inside single pure functions. Recent compaction and harness regressions showed the same pattern: |
| 9 | + |
| 10 | +- provider telemetry from one step influences the next loop decision |
| 11 | +- compaction is applied to the provider-call payload but not persisted to loop history |
| 12 | +- extension hooks can observe or rewrite the wrong slice of conversation state |
| 13 | +- child process sandboxing can make configured files unreadable |
| 14 | +- environment forwarding can silently disable runtime behavior |
| 15 | + |
| 16 | +Real-provider tests are insufficient as the primary regression mechanism. They are expensive, slow, model-dependent, network-dependent, and difficult to reproduce. Unit tests are also insufficient when correctness depends on multi-step state transitions and process-boundary setup. |
| 17 | + |
| 18 | +We need a deterministic test system that drives production transition code where practical, records boundary traces, and asserts structural invariants rather than final model prose. |
| 19 | + |
| 20 | +This ADR is based on: |
| 21 | + |
| 22 | +- `.agent/research/DST/motoko-dst-generalized-system.md` |
| 23 | +- `.agent/research/DST/deterministic-simulation-testing-for-agent-loop-compaction.md` |
| 24 | +- `.agent/plans/DST_v1_Motoko_Core.md` |
| 25 | + |
| 26 | +## Decision |
| 27 | + |
| 28 | +Build Motoko DST as a layered scenario-and-invariant system. |
| 29 | + |
| 30 | +DST will mean: |
| 31 | + |
| 32 | +1. Deterministically model external contracts around Motoko. |
| 33 | +2. Drive real production transition code where feasible. |
| 34 | +3. Record boundary observations into normalized traces. |
| 35 | +4. Assert reusable structural invariants over those traces. |
| 36 | +5. Report scenario id, seed, and trace on failure. |
| 37 | + |
| 38 | +The system will use explicit scripted fakes and recorders rather than assuming mature AILANG effect-handler mocking. AILANG latest docs describe effect mocking as planned, while the local repo currently uses explicit stubs such as `run_v2_with_stub`. |
| 39 | + |
| 40 | +## Layers |
| 41 | + |
| 42 | +### Layer 0: Pure Policy DST |
| 43 | + |
| 44 | +Tests pure helpers and policies: |
| 45 | + |
| 46 | +- compaction thresholds |
| 47 | +- token estimates |
| 48 | +- message elision |
| 49 | +- parser helpers |
| 50 | +- cost arithmetic |
| 51 | +- path/config normalization |
| 52 | + |
| 53 | +This layer can run through `ailang test` or `ailang run` smokes. |
| 54 | + |
| 55 | +### Layer 1: Loop-State DST |
| 56 | + |
| 57 | +Tests `loop_v2` state transitions with scripted provider, extension, and tool behavior. |
| 58 | + |
| 59 | +The first load-bearing seam is provider-call recording in the existing scripted provider path. Compaction is ephemeral: compacted messages are sent to the provider, while the loop recurses on full uncompacted history. Therefore tests must inspect provider-call payloads, not just returned messages. |
| 60 | + |
| 61 | +### Layer 2: Harness-Boundary DST |
| 62 | + |
| 63 | +Tests TypeScript harness behavior before AILANG starts: |
| 64 | + |
| 65 | +- child env preparation |
| 66 | +- sandbox-readable path materialization |
| 67 | +- profile mirroring |
| 68 | +- env allowlist/forwarding |
| 69 | +- spawn arguments |
| 70 | + |
| 71 | +This layer catches bugs like external `SYSTEM_MD` being unreadable under `AILANG_FS_SANDBOX`. |
| 72 | + |
| 73 | +### Layer 3: End-to-End Deterministic Harness DST |
| 74 | + |
| 75 | +Composes the TypeScript harness with a deterministic child/runtime probe or scripted AILANG loop. It must not use real providers or live network as its oracle. |
| 76 | + |
| 77 | +## Core Components |
| 78 | + |
| 79 | +DST will use: |
| 80 | + |
| 81 | +- Scenario fixtures with stable ids. |
| 82 | +- A normalized trace recorder. |
| 83 | +- Reusable invariant functions. |
| 84 | +- Scripted provider fakes returning `std/ai.StepResult`-shaped values. |
| 85 | +- Extension hook fakes that can record inputs and return `PassThrough` or `Compacted`. |
| 86 | +- Tool fakes for deterministic stdout/stderr/exit-code behavior. |
| 87 | +- Harness-boundary fixtures for temp workspaces, external files, and env layouts. |
| 88 | +- Seeded generators for fuzzing scenario parameters after fixed scenarios are stable. |
| 89 | + |
| 90 | +Canonical trace events should include: |
| 91 | + |
| 92 | +```text |
| 93 | +scenario_start |
| 94 | +harness_env_prepared |
| 95 | +child_spawn_prepared |
| 96 | +system_prompt_built |
| 97 | +extension_pre_step_called |
| 98 | +provider_call_prepared |
| 99 | +provider_result |
| 100 | +tool_policy_decision |
| 101 | +tool_result_appended |
| 102 | +loop_totals_updated |
| 103 | +scenario_end |
| 104 | +``` |
| 105 | + |
| 106 | +## Initial Scenario Families |
| 107 | + |
| 108 | +### Compaction |
| 109 | + |
| 110 | +The compaction scenarios prove actual-token behavior and system-message pinning. |
| 111 | + |
| 112 | +Canonical ids: |
| 113 | + |
| 114 | +- `compaction.actual_tokens_drive_next_step` |
| 115 | +- `compaction.system_messages_hidden_from_compactors` |
| 116 | +- `compaction.emergency_exhaustion_estimate_gated` |
| 117 | +- `compaction.actual_tokens_small_context_fail_open` |
| 118 | +- `compaction.provider_payload_vs_uncompacted_history_pressure` |
| 119 | + |
| 120 | +Important invariants: |
| 121 | + |
| 122 | +- provider-call payload contains the pinned system prefix |
| 123 | +- extension `on_pre_step` never receives system messages |
| 124 | +- `last_input_tokens` carries forward from provider result |
| 125 | +- actual-token path uses 60/75/85 tiers only when effective context is positive |
| 126 | +- estimate fallback uses 70/85/95 tiers when `last_input_tokens <= 0` |
| 127 | +- emergency entry is actual-token-gated, but exhaustion is estimate-gated |
| 128 | +- tool-call IDs and tool-result IDs survive elision |
| 129 | + |
| 130 | +### Harness Prompt Materialization |
| 131 | + |
| 132 | +The harness scenarios prove runtime inputs are readable inside the sandbox. |
| 133 | + |
| 134 | +Canonical ids: |
| 135 | + |
| 136 | +- `harness.external_system_md_materialized` |
| 137 | +- `harness.workspace_system_md_not_rewritten` |
| 138 | +- `harness.missing_system_md_fails_loudly` |
| 139 | +- `harness.system_md_forwarded_to_child_env` |
| 140 | + |
| 141 | +Important invariants: |
| 142 | + |
| 143 | +- child env `SYSTEM_MD` points to a sandbox-readable path |
| 144 | +- materialized prompt content equals the original external file |
| 145 | +- child env includes `AILANG_FS_SANDBOX=WORKDIR` |
| 146 | +- non-empty configured system prompts produce non-empty runtime prompt observations |
| 147 | + |
| 148 | +## Preconditions |
| 149 | + |
| 150 | +Do not add generalized DST on top of known-red tests. |
| 151 | + |
| 152 | +Before Layer 1 or Layer 3 DST is enabled: |
| 153 | + |
| 154 | +1. Hydrate AILANG registry dependencies in CI and local setup. |
| 155 | +2. Ensure every package imported by `src/core/ext/registry_generated.ail` is available. |
| 156 | +3. Fix or retire stale compaction tests that still assume no output headroom, especially tests using `test/tiny`. |
| 157 | +4. Update compaction source comments to document both tier tables. |
| 158 | + |
| 159 | +## Implementation Plan |
| 160 | + |
| 161 | +### Phase 0: Restore Test Preconditions |
| 162 | + |
| 163 | +- Add dependency hydration to the test workflow. |
| 164 | +- Repair stale compaction tests around `context_limit - 75000`. |
| 165 | +- Keep current pure compaction checks green. |
| 166 | + |
| 167 | +### Phase 1: Make Existing Boundaries Inspectable |
| 168 | + |
| 169 | +Start from existing infrastructure: |
| 170 | + |
| 171 | +- `src/core/test/stub_step.ail` |
| 172 | +- `src/core/agent_loop_v2.ail#run_v2_with_stub` |
| 173 | +- existing `scripts/smoke_v2_*.ail` |
| 174 | +- existing TypeScript runtime-process tests |
| 175 | + |
| 176 | +Add provider-call recording to the scripted provider path. This is the first required seam. |
| 177 | + |
| 178 | +### Phase 2: Add Scenario Runners |
| 179 | + |
| 180 | +Initial files should stay small: |
| 181 | + |
| 182 | +```text |
| 183 | +scripts/smoke_v2_compaction_actual_dst.ail |
| 184 | +src/tui/src/harness-dst.test.ts |
| 185 | +src/tui/src/runtime-process-env.test.ts |
| 186 | +``` |
| 187 | + |
| 188 | +Only introduce `src/core/test/dst/` after the smaller seams prove useful. |
| 189 | + |
| 190 | +### Phase 3: Add Reusable Invariants |
| 191 | + |
| 192 | +Implement common invariant helpers, including: |
| 193 | + |
| 194 | +- `system_prompt_non_empty` |
| 195 | +- `system_prompt_stable` |
| 196 | +- `provider_calls_have_system_prefix` |
| 197 | +- `system_messages_not_sent_to_pre_step` |
| 198 | +- `tool_call_ids_preserved` |
| 199 | +- `actual_tokens_select_compaction_tier` |
| 200 | +- `provider_payload_compacted_but_history_uncompacted` |
| 201 | +- `emergency_compaction_exhaustion_uses_estimate` |
| 202 | +- `small_effective_context_fails_open` |
| 203 | +- `sandbox_paths_readable_by_child` |
| 204 | + |
| 205 | +### Phase 4: Add Seeded Fuzzing |
| 206 | + |
| 207 | +Add seeded generators around fixed scenarios: |
| 208 | + |
| 209 | +- message history sizes |
| 210 | +- tool output sizes |
| 211 | +- tool-call argument sizes |
| 212 | +- token counts near thresholds |
| 213 | +- extension decisions |
| 214 | +- `SYSTEM_MD` path layouts |
| 215 | +- profile mirror layouts |
| 216 | + |
| 217 | +Every failure must print scenario id, seed, first failed invariant, and normalized trace. |
| 218 | + |
| 219 | +## CI Shape |
| 220 | + |
| 221 | +Fast PR gate: |
| 222 | + |
| 223 | +```bash |
| 224 | +ailang lock |
| 225 | +make test_core |
| 226 | +ailang run --caps IO --entry main scripts/smoke_v2_compaction_actual_dst.ail |
| 227 | +cd src/tui && bun test src/harness-dst.test.ts |
| 228 | +``` |
| 229 | + |
| 230 | +If `ailang lock` does not hydrate every registry dependency, CI must explicitly install missing packages before full-loop DST. |
| 231 | + |
| 232 | +Expanded gate: |
| 233 | + |
| 234 | +```bash |
| 235 | +ailang lock |
| 236 | +make test_dst |
| 237 | +``` |
| 238 | + |
| 239 | +Nightly: |
| 240 | + |
| 241 | +```bash |
| 242 | +DST_SEEDS=500 make test_dst_seeded |
| 243 | +``` |
| 244 | + |
| 245 | +Provider smokes remain optional and supplemental. |
| 246 | + |
| 247 | +## Consequences |
| 248 | + |
| 249 | +Positive: |
| 250 | + |
| 251 | +- Regressions become reproducible without Ollama/OpenRouter. |
| 252 | +- Known bug classes become reusable scenario families. |
| 253 | +- The provider-call payload becomes observable, closing the main loop-state test gap. |
| 254 | +- Harness-boundary failures become testable without a full agent run. |
| 255 | +- Fuzzing can explore scenario parameters without losing deterministic replay. |
| 256 | + |
| 257 | +Negative: |
| 258 | + |
| 259 | +- Some production-safe seams must be added for observability. |
| 260 | +- CI must reliably hydrate AILANG registry dependencies. |
| 261 | +- Tests will need care to avoid duplicating policy constants that should be exported from source. |
| 262 | +- Layer 1 and Layer 3 DST can be blocked by unrelated generated-registry imports until package hydration is fixed. |
| 263 | + |
| 264 | +## Rejected Alternatives |
| 265 | + |
| 266 | +### Real-provider E2E as the primary gate |
| 267 | + |
| 268 | +Rejected because it is flaky, slow, expensive, and not deterministic. |
| 269 | + |
| 270 | +### Pure helper tests only |
| 271 | + |
| 272 | +Rejected because they cannot catch loop-state and harness-boundary failures. |
| 273 | + |
| 274 | +### Extract a pure pre-provider helper first |
| 275 | + |
| 276 | +Deferred. Extension `on_pre_step` carries broad effects, and the most important observation is the actual payload consumed by `dispatch_step`. Provider-call recording is the better first seam. |
| 277 | + |
| 278 | +### Arbitrary black-box fuzzing |
| 279 | + |
| 280 | +Rejected for v1. Fuzzing should be seeded, scenario-shaped, and invariant-driven. |
| 281 | + |
| 282 | +## Open Questions |
| 283 | + |
| 284 | +- Should compaction thresholds and output headroom be exported constants? |
| 285 | +- Should scenario fixtures be AILANG records, JSON files, TypeScript objects, or layer-specific? |
| 286 | +- Should traces be normalized JSONL everywhere, or should AILANG `std/trace` be used for Layer 1? |
| 287 | +- How should virtual time be enabled for `std/clock`, if needed? |
| 288 | +- Can provider-call recording be added without changing production provider behavior? |
| 289 | + |
0 commit comments