Skip to content

Commit 28efe2a

Browse files
committed
fix(sleep): make validation and model diagnostics truthful
1 parent 09c6dab commit 28efe2a

10 files changed

Lines changed: 434 additions & 33 deletions

File tree

scripts/train.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,29 @@ def load_config(args: argparse.Namespace) -> dict:
418418
DeprecationWarning,
419419
stacklevel=2,
420420
)
421+
_structured_credential_guidance = dict(_credential_guidance)
422+
_structured_credential_guidance.update({
423+
# MiniMax is currently configured through one shared runtime client; a
424+
# secret supplied through either role-shaped field belongs in the
425+
# shared MINIMAX_API_KEY environment variable instead.
426+
"optimizer_minimax_api_key": _credential_guidance["minimax_api_key"],
427+
"target_minimax_api_key": _credential_guidance["minimax_api_key"],
428+
})
429+
_credential_suffixes = ("api_key", "api-key", "token", "secret", "password")
430+
for _override in getattr(args, "cfg_options", None) or []:
431+
_key, _separator, _value = str(_override).partition("=")
432+
_key = _key.strip()
433+
_leaf = _key.casefold().rsplit(".", 1)[-1]
434+
_guidance = _structured_credential_guidance.get(_leaf)
435+
if not _guidance and _leaf.endswith(_credential_suffixes):
436+
_guidance = "a backend-specific environment variable or managed identity"
437+
if _separator and _guidance and _value:
438+
warnings.warn(
439+
f"--cfg-options {_key}=... exposes a credential in "
440+
f"the process command line: provide it via {_guidance} instead.",
441+
DeprecationWarning,
442+
stacklevel=2,
443+
)
421444

422445
cfg = _load(args.config, overrides=args.cfg_options)
423446
structured = is_structured(cfg)

skillopt_sleep/consolidate.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,15 @@ def _gate_apply(doc: str, edits: List[EditRecord], which: str) -> str:
295295
# `accepted` is False makes the headline contradict the outcome.
296296
if not accepted and action in {"accept", "accept_new_best"}:
297297
action = "reject"
298+
# A per-target trial can improve and tentatively apply an edit, while a
299+
# later fresh final replay regresses. The returned documents already
300+
# roll back in that case; keep the edit bookkeeping/report consistent
301+
# by moving those tentative edits into the rejected set as well.
302+
if not accepted and all_applied:
303+
for edit in all_applied:
304+
if edit not in all_rejected:
305+
all_rejected.append(edit)
306+
all_applied = []
298307

299308
if ev is not None:
300309
w = max(0.0, min(1.0, float(gate_mixed_weight)))

skillopt_sleep/cycle.py

Lines changed: 71 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -32,38 +32,80 @@
3232

3333
# ── Model-swap detection (F16) ───────────────────────────────
3434
def _make_model_key(cfg: SleepConfig) -> str:
35-
"""Stable string identifying the effective backend/model role(s)."""
36-
backend = str(cfg.get("backend", "mock") or "mock")
37-
model = str(cfg.get("model", "") or "")
38-
split_keys = (
39-
"optimizer_backend",
40-
"optimizer_model",
41-
"target_backend",
42-
"target_model",
43-
)
44-
if not any(cfg.get(key, "") for key in split_keys):
45-
# Preserve the original state format for ordinary single-backend runs.
46-
return f"{backend}::{model}"
47-
48-
optimizer_backend = str(cfg.get("optimizer_backend", "") or backend)
49-
optimizer_model = str(cfg.get("optimizer_model", "") or model)
50-
target_backend = str(cfg.get("target_backend", "") or backend)
51-
target_model = str(cfg.get("target_model", "") or model)
52-
return (
53-
f"optimizer={optimizer_backend}::{optimizer_model};"
54-
f"target={target_backend}::{target_model}"
55-
)
35+
"""Stable string identifying the backend object(s) actually used.
36+
37+
Model-change detection is advisory, so resolving its diagnostic key must
38+
never become an earlier failure point than construction of the real
39+
backend. Fall back to a credential-free description of the configured
40+
roles if a backend constructor cannot be used in this diagnostic path.
41+
"""
42+
try:
43+
effective = build_backend(
44+
backend=cfg.get("backend", "mock"),
45+
model=cfg.get("model", ""),
46+
optimizer_backend=cfg.get("optimizer_backend", ""),
47+
optimizer_model=cfg.get("optimizer_model", ""),
48+
target_backend=cfg.get("target_backend", ""),
49+
target_model=cfg.get("target_model", ""),
50+
codex_path=cfg.get("codex_path", ""),
51+
cursor_path=cfg.get("cursor_path", ""),
52+
azure_endpoint=cfg.get("azure_endpoint", ""),
53+
project_dir=cfg.get("invoked_project", "") or os.getcwd(),
54+
)
55+
except Exception:
56+
backend = str(cfg.get("backend", "mock") or "mock")
57+
model = str(cfg.get("model", "") or "")
58+
split_keys = (
59+
"optimizer_backend",
60+
"optimizer_model",
61+
"target_backend",
62+
"target_model",
63+
)
64+
if not any(cfg.get(key, "") for key in split_keys):
65+
return f"configured:{backend}::{model}"
66+
optimizer_backend = str(cfg.get("optimizer_backend", "") or backend)
67+
optimizer_model = str(cfg.get("optimizer_model", "") or model)
68+
target_backend = str(cfg.get("target_backend", "") or backend)
69+
target_model = str(cfg.get("target_model", "") or model)
70+
return (
71+
f"configured:optimizer={optimizer_backend}::{optimizer_model};"
72+
f"target={target_backend}::{target_model}"
73+
)
74+
return _make_backend_key(effective)
5675

5776

58-
def _check_model_change(cfg: SleepConfig, state: SleepState) -> None:
77+
def _make_backend_key(backend: Backend) -> str:
78+
"""Describe resolved aliases/defaults without exposing credentials."""
79+
target = getattr(backend, "target", None)
80+
optimizer = getattr(backend, "optimizer", None)
81+
if target is not None and optimizer is not None:
82+
return (
83+
f"optimizer={_make_backend_key(optimizer)};"
84+
f"target={_make_backend_key(target)}"
85+
)
86+
name = str(getattr(backend, "name", backend.__class__.__name__) or "")
87+
model = str(getattr(backend, "model", "") or "")
88+
return f"{name}::{model}"
89+
90+
91+
def _check_model_change(
92+
cfg: SleepConfig, state: SleepState, backend: Backend | None = None
93+
) -> None:
5994
"""Warn when the backend/model has changed since the last night.
6095
6196
Skill text is backend-specific; adopting edits from a different model's
6297
reflections into a new model's skill file can cause regressions.
6398
This is advisory only — the cycle continues either way.
6499
"""
65-
current_key = _make_model_key(cfg)
100+
current_key = (
101+
_make_backend_key(backend) if backend is not None else _make_model_key(cfg)
102+
)
66103
prior_key = state.last_model_key
104+
if prior_key and state.last_model_key_format < 2:
105+
# Version 1 stored raw configuration rather than the resolved backend
106+
# model. Defaults and aliases make that value impossible to compare
107+
# truthfully, so migrate silently on the next successful night.
108+
return
67109
if prior_key and prior_key != current_key:
68110
print(
69111
f"[sleep] WARNING: model changed since last night "
@@ -143,7 +185,8 @@ def _render_report_md(report: SleepReport, cfg: SleepConfig) -> str:
143185
if report.unmatched_edits:
144186
lines.append("## Proposed but changed nothing (never reached the gate)")
145187
lines.append(
146-
"_Anchor not found, duplicate/empty add, or an unknown op. "
188+
"_Anchor not found, replacement already present, duplicate/empty "
189+
"add, or an unknown op. "
147190
"These were never scored — check the anchor text if a rule you expected is missing._")
148191
for e in report.unmatched_edits:
149192
anchor = f" \n _anchor: `{e.anchor}`_" if e.anchor else ""
@@ -180,10 +223,7 @@ def run_sleep_cycle(
180223
"""
181224
cfg = cfg or load_config()
182225
state = SleepState.load(cfg.state_path)
183-
_check_model_change(cfg, state) # F16: warn if model changed between nights
184-
night = state.begin_night(clock)
185226
project = _project_paths(cfg)
186-
started = _now_iso(clock)
187227

188228
backend = backend or build_backend(
189229
backend=cfg.get("backend", "mock"),
@@ -198,6 +238,9 @@ def run_sleep_cycle(
198238
preferences=cfg.get("preferences", ""),
199239
project_dir=project,
200240
)
241+
_check_model_change(cfg, state, backend) # F16: warn if model changed between nights
242+
night = state.begin_night(clock)
243+
started = _now_iso(clock)
201244
backend.preferences = cfg.get("preferences", "")
202245
_progress(cfg, f"night {night}: project={project} backend={backend.name}")
203246

@@ -466,7 +509,7 @@ def run_sleep_cycle(
466509
"baseline": result.baseline_score, "candidate": result.candidate_score,
467510
"n_tasks": len(tasks), "staging": staging_dir,
468511
})
469-
state.set_last_model_key(_make_model_key(cfg)) # F16: track model for next night
512+
state.set_last_model_key(_make_backend_key(backend)) # F16: track resolved model
470513
# ── 6. adopt (opt-in) ────────────────────────────────────────────
471514
if cfg.get("auto_adopt") and result.accepted:
472515
adopted_paths = adopt_staging(staging_dir)

skillopt_sleep/judges.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,16 +96,44 @@ def validate_checks(judge: Any) -> Tuple[List[str], List[str]]:
9696
continue
9797
op = c.get("op", "")
9898
arg = c.get("arg")
99+
if not isinstance(op, str):
100+
errors.append(
101+
f"check #{i} op must be a string, got {type(op).__name__}"
102+
)
103+
continue
104+
if op in {"regex", "section_present", "contains", "tool_called"} and (
105+
arg is None or not str(arg).strip()
106+
):
107+
errors.append(f"check #{i} {op} needs a non-empty arg")
108+
continue
99109
if op == "regex":
100110
try:
101111
re.compile(str(arg))
102112
except re.error as exc:
103113
errors.append(f"check #{i} regex does not compile ({exc}): {arg!r}")
104114
elif op in {"max_chars", "min_chars"}:
105115
try:
106-
int(arg)
107-
except (TypeError, ValueError):
116+
if isinstance(arg, bool):
117+
raise ValueError
118+
if isinstance(arg, int):
119+
bound = arg
120+
elif isinstance(arg, float):
121+
if not arg.is_integer():
122+
raise ValueError
123+
bound = int(arg)
124+
elif isinstance(arg, str) and re.fullmatch(
125+
r"[+-]?\d+", arg.strip()
126+
):
127+
bound = int(arg.strip())
128+
else:
129+
raise ValueError
130+
except (OverflowError, TypeError, ValueError):
108131
errors.append(f"check #{i} {op} needs an integer arg, got {arg!r}")
132+
else:
133+
if bound < 0:
134+
errors.append(f"check #{i} {op} cannot be negative, got {bound}")
135+
elif op == "min_chars" and bound == 0:
136+
warnings.append(f"check #{i} min_chars=0 always passes")
109137
elif op not in KNOWN_OPS:
110138
warnings.append(f"check #{i} has unknown op {op!r} — it always passes")
111139
return errors, warnings

skillopt_sleep/memory.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ def apply_edits_detailed(
9393
whenever it left the document unchanged:
9494
9595
* ``delete``/``replace`` whose anchor matches no existing line;
96+
* ``replace`` whose replacement is already present verbatim;
9697
* ``add`` whose content duplicates an existing line (normalized), or is
9798
empty/whitespace;
9899
* any unrecognized op.
@@ -129,12 +130,13 @@ def apply_edits_detailed(
129130
unmatched.append(e)
130131
elif op == "replace":
131132
anchor = _norm(e.anchor)
133+
replacement = e.content.strip()
132134
new_lines = []
133135
changed = False
134136
for line in lines:
135137
if anchor and anchor in _norm(line):
136-
new_lines.append(e.content.strip())
137-
changed = True
138+
new_lines.append(replacement)
139+
changed = changed or replacement != line
138140
else:
139141
new_lines.append(line)
140142
if changed:

skillopt_sleep/state.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def _now_iso(clock: Optional[float] = None) -> str:
3030
"history": [], # list of per-night summaries
3131
"task_archive": [], # capped list of past mined tasks (for associative recall)
3232
"last_model_key": "", # "backend::model" string used in the last successful night (F16)
33+
"last_model_key_format": 1, # v1=config text; v2=resolved backend/model
3334
}
3435

3536

@@ -103,3 +104,11 @@ def last_model_key(self) -> str:
103104

104105
def set_last_model_key(self, key: str) -> None:
105106
self.data["last_model_key"] = key
107+
self.data["last_model_key_format"] = 2
108+
109+
@property
110+
def last_model_key_format(self) -> int:
111+
try:
112+
return int(self.data.get("last_model_key_format", 1))
113+
except (TypeError, ValueError):
114+
return 1

0 commit comments

Comments
 (0)