Stage 7 — final stage of the realism migration. Canary plants are
now scheduled by the same realism planner that handles inert content,
keeping the orchestrator as the single decision point and avoiding
duplicate diurnal / persona / rate-limit logic in the canary
subsystem.
New surface:
- decnet/canary/cultivator.py: cultivate(plan, repo) builds a
CanaryContext, calls the right generator (canary_aws_creds ->
aws_creds, canary_mysql_dump -> mysql_dump, …), persists the
canary_tokens row before plant so the canary worker can attribute
callbacks even on plant-time previews. Resolves canary placements
to credible operator paths (~/.aws/credentials, ~/.ssh/id_rsa,
/var/backups/db_backup.sql).
- realism/planner.py adds 8 canary content_classes uniformly weighted
inside a 3% probability gate. Hard-capped: each tick at most one
canary; create branch falls through to inert otherwise.
- scheduler.pick_file dispatches canary content_class to the
cultivator; FileAction grows an optional content_bytes field so
binary canary artifacts (DOCX/PDF/honeydoc) survive the wire
intact instead of being utf-8 round-tripped.
- SSHDriver._run_file uses content_bytes when set, falls back to
encoding the str content otherwise.
Stealth (per feedback_stealth.md): cultivator does not introduce
any DECNET literal; the underlying generators are already
stealth-clean and the test suite asserts the contract holds.
Tests cover round-tripping every canary class through the cultivator,
verifying placement-path conventions, persona-login normalisation
("John Smith" -> /home/johnsmith/.aws/credentials), and the
no-DECNET-leak invariant.
115 lines
3.8 KiB
Python
115 lines
3.8 KiB
Python
"""Realism planner — picks (decky, persona, class, action, mtime).
|
|
|
|
Stage 3 ships create-only plans; the edit branch lands in 3b. Tests
|
|
pin the diurnal gate, the eligibility filter, and the create
|
|
contract.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import random
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
|
|
from decnet.realism.personas import EmailPersona
|
|
from decnet.realism.planner import pick
|
|
from decnet.realism.taxonomy import ContentClass
|
|
|
|
|
|
def _persona(name: str = "admin", window: str = "00:00-00:00") -> EmailPersona:
|
|
return EmailPersona(
|
|
name=name,
|
|
email=f"{name}@corp.com",
|
|
role="ops",
|
|
tone="direct",
|
|
active_hours=window,
|
|
)
|
|
|
|
|
|
def _decky(uuid: str = "u1", name: str = "decky-01", personas=None) -> dict:
|
|
return {
|
|
"uuid": uuid,
|
|
"name": name,
|
|
"_realism_personas": personas or [_persona()],
|
|
}
|
|
|
|
|
|
_NOW = datetime(2026, 4, 27, 14, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
def test_pick_returns_none_when_no_deckies() -> None:
|
|
assert pick([], _NOW) is None
|
|
|
|
|
|
def test_pick_returns_none_when_decky_has_no_personas() -> None:
|
|
assert pick([{"uuid": "u1", "name": "d", "_realism_personas": []}], _NOW) is None
|
|
|
|
|
|
def test_pick_filters_personas_outside_window() -> None:
|
|
# A persona pegged to 01:00-02:00 with now=14:00 must not be picked.
|
|
out_of_hours = _persona(window="01:00-02:00")
|
|
deckies = [_decky(personas=[out_of_hours])]
|
|
assert pick(deckies, _NOW) is None
|
|
|
|
|
|
def test_pick_returns_create_plan_with_mtime_in_past() -> None:
|
|
deckies = [_decky()]
|
|
plan = pick(deckies, _NOW, rand=random.Random(0))
|
|
assert plan is not None
|
|
assert plan.action == "create"
|
|
assert plan.decky_uuid == "u1"
|
|
assert plan.persona == "admin"
|
|
assert plan.target_path.startswith("/")
|
|
assert plan.body_hint
|
|
assert plan.mtime < _NOW
|
|
|
|
|
|
def test_pick_distributes_across_user_and_system_classes() -> None:
|
|
deckies = [_decky()]
|
|
seen: set[ContentClass] = set()
|
|
for seed in range(80):
|
|
plan = pick(deckies, _NOW, rand=random.Random(seed))
|
|
if plan is not None:
|
|
seen.add(plan.content_class)
|
|
# Across 80 seeds we should hit both buckets — at least one user
|
|
# class and at least one system class — otherwise the weights or
|
|
# the 70/30 split is broken.
|
|
user_classes = {c for c in seen if c.is_user_class()}
|
|
system_classes = {c for c in seen if c.is_system_class()}
|
|
assert user_classes, f"no user-class plans in 80 trials: {seen}"
|
|
assert system_classes, f"no system-class plans in 80 trials: {seen}"
|
|
|
|
|
|
def test_canary_picks_are_rare() -> None:
|
|
"""Stage 7: canary content_classes ARE picked, but bounded.
|
|
|
|
The documented rate is ~3% of file picks per
|
|
decnet/realism/planner.py:_CANARY_PROBABILITY. We trial a large
|
|
sample and assert the rate stays under a generous ceiling so a
|
|
typo bumping the constant to 30% explodes here loudly.
|
|
"""
|
|
deckies = [_decky()]
|
|
canary_count = 0
|
|
create_count = 0
|
|
for seed in range(500):
|
|
plan = pick(deckies, _NOW, rand=random.Random(seed))
|
|
if plan is None:
|
|
continue
|
|
create_count += 1
|
|
if plan.content_class.is_canary():
|
|
canary_count += 1
|
|
# 3% target with a 6% upper bound — sampling noise on 500 trials
|
|
# is comfortably below this for the documented rate.
|
|
rate = canary_count / max(1, create_count)
|
|
assert rate <= 0.06, f"canary rate {rate:.2%} exceeds 6% ceiling"
|
|
assert canary_count > 0, "expected at least one canary across 500 seeds"
|
|
|
|
|
|
def test_pick_persists_persona_window_in_notes() -> None:
|
|
plan = pick([_decky()], _NOW, rand=random.Random(0))
|
|
assert plan is not None
|
|
# The plan's notes carry the persona name and the window — useful
|
|
# for the dashboard's "why this file" inspector.
|
|
assert any("persona=admin" in n for n in plan.notes)
|
|
assert any("window=" in n for n in plan.notes)
|