Files
DECNET/decnet/realism/planner.py

390 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Realism planner — picks the next ``(decky, persona, class, action)`` tuple.
Stage 3: returns ``create``-only plans (the edit branch lands in
stage 3b). Pure-function, deterministic given the same inputs:
caller passes deckies (with personas pre-resolved on each row),
``now``, and an RNG.
The persona resolution split — topology-pool vs. global-pool — is
the orchestrator's job, not the planner's. Each decky dict reaching
:func:`pick` carries a ``_realism_personas`` key with the resolved
:class:`~decnet.realism.personas.EmailPersona` list. Keeps the
planner test-isolated and avoids forcing it to know about the
:class:`~decnet.web.db.repository.BaseRepository` / topology pool /
global pool.
Diurnal gating uses :func:`decnet.realism.diurnal.in_work_hours` per
persona; we filter the (decky, persona) pairs *before* picking, so a
persona outside its window is never considered.
"""
from __future__ import annotations
import secrets
import threading
from datetime import datetime
from typing import Any, Optional, Sequence
from decnet.realism import bodies, naming
from decnet.realism.diurnal import in_work_hours, sample_mtime
from decnet.realism.personas import EmailPersona
from decnet.realism.taxonomy import ContentClass, Plan, PlanAction # noqa: F401
# Stage-3 weighted sampling defaults:
# * User content (notes/todo/draft/script) gets the bulk — those are
# the realism win when a persona "looks busy."
# * System content (cron/daemon/cache) is plausible filler.
# * Email + canary are owned by other paths and not picked here.
# * Canary classes are picked rarely. Each plant materialises a real
# CanaryToken row + DNS slug + HTTP URL — flooding the fleet makes
# the dashboard noisy. ~3% of file picks land here.
#
# These are the *defaults*. Operator-tuned overrides arrive via
# :func:`apply_payload` (admin PUT /api/v1/realism/config). The
# orchestrator worker periodically refreshes the in-process state from
# the ``realism_config`` table; pick() reads the live globals each call.
_DEFAULT_USER_CLASS_WEIGHTS: tuple[tuple[ContentClass, int], ...] = (
(ContentClass.NOTE, 30),
(ContentClass.TODO, 20),
(ContentClass.DRAFT, 15),
(ContentClass.SCRIPT, 10),
)
_DEFAULT_SYSTEM_CLASS_WEIGHTS: tuple[tuple[ContentClass, int], ...] = (
(ContentClass.LOG_CRON, 12),
(ContentClass.LOG_DAEMON, 8),
(ContentClass.CACHE_TMP, 5),
)
_DEFAULT_CANARY_CLASS_WEIGHTS: tuple[tuple[ContentClass, int], ...] = (
(ContentClass.CANARY_AWS_CREDS, 1),
(ContentClass.CANARY_ENV_FILE, 1),
(ContentClass.CANARY_GIT_CONFIG, 1),
(ContentClass.CANARY_SSH_KEY, 1),
(ContentClass.CANARY_HONEYDOC, 1),
(ContentClass.CANARY_HONEYDOC_DOCX, 1),
(ContentClass.CANARY_HONEYDOC_PDF, 1),
(ContentClass.CANARY_MYSQL_DUMP, 1),
(ContentClass.CANARY_FINGERPRINT_HTML, 1),
(ContentClass.CANARY_FINGERPRINT_SVG, 1),
)
_DEFAULT_CANARY_PROBABILITY = 0.03
# Live (mutable) globals — reassigned by :func:`apply_payload`. pick()
# reads these. Reset to defaults via :func:`reset_to_defaults` (used by
# tests + the API DELETE path).
_USER_CLASS_WEIGHTS: tuple[tuple[ContentClass, int], ...] = _DEFAULT_USER_CLASS_WEIGHTS
_SYSTEM_CLASS_WEIGHTS: tuple[tuple[ContentClass, int], ...] = _DEFAULT_SYSTEM_CLASS_WEIGHTS
_CANARY_CLASS_WEIGHTS: tuple[tuple[ContentClass, int], ...] = _DEFAULT_CANARY_CLASS_WEIGHTS
_CANARY_PROBABILITY: float = _DEFAULT_CANARY_PROBABILITY
_planner_lock = threading.Lock()
def _serialize_weights(
weights: tuple[tuple[ContentClass, int], ...],
) -> list[dict[str, Any]]:
return [{"content_class": cls.value, "weight": w} for cls, w in weights]
def _parse_weights(
raw: Any, allowed: set[ContentClass],
) -> tuple[tuple[tuple[ContentClass, int], ...], list[str]]:
"""Parse ``[{"content_class": "...", "weight": N}, ...]`` into the
planner's internal tuple shape.
Returns ``(weights, dropped)`` where *dropped* is the list of
``content_class`` values that were valid enum members but not in
*allowed* (e.g. a canary class pasted onto the user list). Callers
surface *dropped* in the API response so the operator can see the
entry didn't land without having to re-read the config.
Raises ``ValueError`` on structural problems (non-list, non-int
weight, negative weight, empty result) so the API can return 400.
"""
if not isinstance(raw, list):
raise ValueError("weights must be a list")
out: list[tuple[ContentClass, int]] = []
dropped: list[str] = []
for entry in raw:
if not isinstance(entry, dict):
raise ValueError("each weight entry must be an object")
cls_name = entry.get("content_class")
if not isinstance(cls_name, str):
raise ValueError(f"content_class must be a string, got {cls_name!r}")
weight = entry.get("weight")
if not isinstance(weight, int) or weight < 0:
raise ValueError(
f"weight for {cls_name!r} must be a non-negative integer"
)
try:
cls = ContentClass(cls_name)
except (ValueError, TypeError):
raise ValueError(f"unknown content_class: {cls_name!r}")
if cls not in allowed:
dropped.append(cls.value)
continue
out.append((cls, weight))
if not out:
raise ValueError("weights list resolved to zero valid entries")
if sum(w for _, w in out) <= 0:
raise ValueError("weights must sum to a positive number")
return tuple(out), dropped
_USER_CLASSES: set[ContentClass] = {
ContentClass.NOTE, ContentClass.TODO, ContentClass.DRAFT, ContentClass.SCRIPT,
}
_SYSTEM_CLASSES: set[ContentClass] = {
ContentClass.LOG_CRON, ContentClass.LOG_DAEMON, ContentClass.CACHE_TMP,
}
_CANARY_CLASSES: set[ContentClass] = {
ContentClass.CANARY_AWS_CREDS, ContentClass.CANARY_ENV_FILE,
ContentClass.CANARY_GIT_CONFIG, ContentClass.CANARY_SSH_KEY,
ContentClass.CANARY_HONEYDOC, ContentClass.CANARY_HONEYDOC_DOCX,
ContentClass.CANARY_HONEYDOC_PDF, ContentClass.CANARY_MYSQL_DUMP,
ContentClass.CANARY_FINGERPRINT_HTML, ContentClass.CANARY_FINGERPRINT_SVG,
}
def current_payload() -> dict[str, Any]:
"""Export the live planner config as a JSON-safe dict.
Wire shape returned by ``GET /api/v1/realism/config``."""
return {
"user_class_weights": _serialize_weights(_USER_CLASS_WEIGHTS),
"system_class_weights": _serialize_weights(_SYSTEM_CLASS_WEIGHTS),
"canary_class_weights": _serialize_weights(_CANARY_CLASS_WEIGHTS),
"canary_probability": _CANARY_PROBABILITY,
}
def apply_payload(payload: dict[str, Any]) -> list[str]:
"""Override the planner's live globals from a wire payload.
Validates structurally and rebinds module-level names atomically
per field — partial failures don't leave the planner in a torn
state because validation happens before any rebind.
Returns the list of ``content_class`` values that were dropped
because they didn't belong on their target list (e.g. a canary
class on the user list). Callers should surface this in the API
response so operators know their entry didn't land.
Unknown fields are ignored (forward-compat); fields not present
leave the corresponding global untouched.
"""
global _USER_CLASS_WEIGHTS, _SYSTEM_CLASS_WEIGHTS
global _CANARY_CLASS_WEIGHTS, _CANARY_PROBABILITY
new_user = _USER_CLASS_WEIGHTS
new_system = _SYSTEM_CLASS_WEIGHTS
new_canary = _CANARY_CLASS_WEIGHTS
new_prob = _CANARY_PROBABILITY
all_dropped: list[str] = []
if "user_class_weights" in payload:
new_user, dropped = _parse_weights(payload["user_class_weights"], _USER_CLASSES)
all_dropped.extend(dropped)
if "system_class_weights" in payload:
new_system, dropped = _parse_weights(
payload["system_class_weights"], _SYSTEM_CLASSES,
)
all_dropped.extend(dropped)
if "canary_class_weights" in payload:
new_canary, dropped = _parse_weights(
payload["canary_class_weights"], _CANARY_CLASSES,
)
all_dropped.extend(dropped)
if "canary_probability" in payload:
prob = payload["canary_probability"]
if not isinstance(prob, (int, float)) or not (0.0 <= prob <= 1.0):
raise ValueError("canary_probability must be in [0.0, 1.0]")
new_prob = float(prob)
with _planner_lock:
_USER_CLASS_WEIGHTS = new_user
_SYSTEM_CLASS_WEIGHTS = new_system
_CANARY_CLASS_WEIGHTS = new_canary
_CANARY_PROBABILITY = new_prob
return all_dropped
def reset_to_defaults() -> None:
"""Restore hardcoded defaults. Used by tests and the API reset path."""
global _USER_CLASS_WEIGHTS, _SYSTEM_CLASS_WEIGHTS
global _CANARY_CLASS_WEIGHTS, _CANARY_PROBABILITY
with _planner_lock:
_USER_CLASS_WEIGHTS = _DEFAULT_USER_CLASS_WEIGHTS
_SYSTEM_CLASS_WEIGHTS = _DEFAULT_SYSTEM_CLASS_WEIGHTS
_CANARY_CLASS_WEIGHTS = _DEFAULT_CANARY_CLASS_WEIGHTS
_CANARY_PROBABILITY = _DEFAULT_CANARY_PROBABILITY
def _weighted_pick(
weights: tuple[tuple[ContentClass, int], ...],
rng: secrets.SystemRandom,
) -> ContentClass:
total = sum(w for _, w in weights)
target = rng.randint(1, total)
running = 0
for cls, w in weights:
running += w
if target <= running:
return cls
return weights[-1][0] # unreachable, satisfy mypy
def _eligible_pairs(
deckies: Sequence[dict[str, Any]],
now: datetime,
) -> list[tuple[dict[str, Any], EmailPersona]]:
"""Cross-product of deckies × resolved personas, diurnal-filtered.
A decky with no personas (empty ``_realism_personas``) is skipped
entirely; same fail-quiet semantics as the emailgen scheduler.
"""
out: list[tuple[dict[str, Any], EmailPersona]] = []
for decky in deckies:
personas: list[EmailPersona] = decky.get("_realism_personas") or []
for persona in personas:
if in_work_hours(persona.active_hours, now):
out.append((decky, persona))
return out
def pick(
deckies: Sequence[dict[str, Any]],
now: datetime,
*,
edit_candidate: Optional[dict[str, Any]] = None,
rand: Optional[secrets.SystemRandom] = None,
) -> Optional[Plan]:
"""Return a single :class:`Plan` for the orchestrator's tick.
Stage-3b policy: weighted action roll — 60% create, 30% edit, 10%
"leave alone" (planner returns ``None`` to skip). When the roll
is "edit" and *edit_candidate* is set (a row from
:meth:`BaseRepository.pick_random_synthetic_file_for_edit`), we
return an edit Plan; otherwise we fall through to create.
The orchestrator scheduler is responsible for fetching the edit
candidate before calling — keeps this function pure-of-DB and
test-friendly.
Returns ``None`` when no eligible (decky, persona) pair exists or
when the action roll lands on "leave alone."
"""
rng = rand or secrets.SystemRandom()
eligible = _eligible_pairs(deckies, now)
if not eligible:
return None
# Action roll. Edit only fires when there's a candidate from the
# repo — otherwise we either re-roll to create or skip.
roll = rng.random()
if roll < 0.10:
return None # "leave alone" — quiet tick is realism too
if roll < 0.40 and edit_candidate is not None:
return _edit_plan(edit_candidate, now, rng)
decky, persona = rng.choice(eligible)
# Canary first — they're rare (~3% of file picks), uniformly
# weighted across generators. Falling here means the orchestrator
# plants a callback-bearing artifact this tick instead of an
# inert one.
if rng.random() < _CANARY_PROBABILITY:
content_class = _weighted_pick(_CANARY_CLASS_WEIGHTS, rng)
# Canary placement is the cultivator's job — plan.target_path
# is advisory; a "" lets the cultivator override entirely.
target_path = ""
body_hint = None
mtime = sample_mtime(persona.active_hours, now, rand=rng)
return Plan(
decky_uuid=decky["uuid"],
decky_name=decky["name"],
persona=persona.name,
content_class=content_class,
action="create",
target_path=target_path,
mtime=mtime,
body_hint=body_hint,
notes=(
f"persona={persona.name}",
f"class={content_class.value}",
"kind=canary",
),
)
# User vs system content — biased toward user (realism wins are
# bigger there).
if rng.random() < 0.7:
content_class = _weighted_pick(_USER_CLASS_WEIGHTS, rng)
else:
content_class = _weighted_pick(_SYSTEM_CLASS_WEIGHTS, rng)
target_path = naming.make_path(content_class, persona.name, rand=rng)
body_hint = bodies.make_body(content_class, persona.name, rand=rng)
mtime = sample_mtime(persona.active_hours, now, rand=rng)
return Plan(
decky_uuid=decky["uuid"],
decky_name=decky["name"],
persona=persona.name,
content_class=content_class,
action="create",
target_path=target_path,
mtime=mtime,
body_hint=body_hint,
notes=(
f"persona={persona.name}",
f"class={content_class.value}",
f"window={persona.active_hours}",
),
)
def _edit_plan(
candidate: dict[str, Any],
now: datetime,
rng: secrets.SystemRandom,
) -> Optional[Plan]:
"""Build an edit-action :class:`Plan` from a synthetic_files row.
The candidate dict is the shape :meth:`BaseRepository.list_synthetic_files`
returns — we only need ``decky_uuid``, ``path``, ``persona``,
``content_class``, ``last_body``, ``uuid``. Returns ``None`` if
the candidate's content_class is somehow not editable (defensive
— the repo query already filters those out).
"""
try:
cls = ContentClass(candidate["content_class"])
except (KeyError, ValueError):
return None
if cls.is_canary() or cls == ContentClass.CACHE_TMP:
return None
# mtime: edits bump forward by ~hours-to-days, but never past now.
# We model as "the file was edited some time after creation but
# before now" — sample_mtime with a tighter cap keeps it recent.
edit_mtime = sample_mtime(
"00:00-00:00", now, rand=rng,
backdate_min_hours=1.0, backdate_max_days=2.0,
)
return Plan(
decky_uuid=candidate["decky_uuid"],
decky_name=candidate.get("decky_name", ""),
persona=candidate.get("persona", ""),
content_class=cls,
action="edit",
target_path=candidate["path"],
mtime=edit_mtime,
body_hint=None, # edit uses previous_body, not a fresh hint
previous_body=candidate.get("last_body", ""),
notes=(
f"persona={candidate.get('persona', '')}",
f"class={cls.value}",
"action=edit",
f"synthetic_file_uuid={candidate.get('uuid', '')}",
),
)