Skip to content

Commit ed85b40

Browse files
fix(planner): fall back to a servable area instead of an empty skeleton
The default RCS profile carries bottleneck M1; _SLOT_TO_AREA maps M1 to area 3 (Ограничения); area 3 has no CAT.002/003 practices at all, and CAT.001 stays empty whenever GUIDE_KIT_CURRICULUM_PATH is unset — which it is on the pilot runtime today. The existing fallbacks only retried the SAME area in the other catalog, so every pilot without a computed profile got element_id=None and the LLM improvised the entire lesson with no catalog anchor. plan_horizon now falls back across areas once both catalogs come up empty for the primary one: _nearest_servable_area picks the servable area with the widest remaining gap (4 - depth), so real progress data — including the interim LMS snapshot wired in WP-406 Ф31 — still steers the choice, with a deterministic tie-break so a given seed keeps reproducing the same plan. Areas that were already servable resolve exactly as before. Note this got MORE likely with the LMS snapshot: raising depth for knowledge and tools widens the relative gap of the areas the mapping does not cover yet. 11 tests in tests/test_planner_area_fallback.py, including the area-3 premise itself so the guard fails loudly if the catalog gains constraints practices. Two pre-existing test_decomposer.py failures are untouched and unrelated (verified identical with this change stashed). Refs: WP-406 Ф31
1 parent 0f10910 commit ed85b40

2 files changed

Lines changed: 174 additions & 0 deletions

File tree

‎generator/planner.py‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,46 @@ def _get_recent_element_ids(recent: list[RecentLesson], n: int = 5) -> set[str]:
444444
return {r.element_id for r in recent[:n]}
445445

446446

447+
# Area order used to break ties when falling back to another area — the planner
448+
# must be deterministic for the same input (same seed → same plan).
449+
_AREA_KEYS_BY_INDEX = ["knowledge", "tools", "constraints", "environment", "organism"]
450+
451+
452+
def _area_has_elements(area: int, stage: int) -> bool:
453+
"""True if some catalog can serve this area at this stage."""
454+
for meta in {**CAT002_ELEMENTS, **CAT003_ELEMENTS}.values():
455+
if meta["area"] == area and meta["entry_stage"] <= stage:
456+
return True
457+
for meta in _load_cat001().values():
458+
if meta.get("area") == area and meta.get("entry_stage", 1) <= stage:
459+
return True
460+
return False
461+
462+
463+
def _nearest_servable_area(
464+
primary_area: int, stage: int, mastery_by_area: dict[str, int]
465+
) -> tuple[int | None, str]:
466+
"""Pick a replacement area when `primary_area` has nothing in any catalog.
467+
468+
Preference is the same one _choose_area uses when it works normally — the
469+
largest remaining gap (4 - depth), so real progress data (including the
470+
interim LMS snapshot) still steers the choice. Ties break on the lower area
471+
number, so the plan stays reproducible for a given seed.
472+
473+
Returns (area, reason) or (None, reason) when no area can be served at all —
474+
the caller then keeps the empty skeleton, exactly as before this fallback.
475+
"""
476+
servable = [a for a in range(1, 6) if a != primary_area and _area_has_elements(a, stage)]
477+
if not servable:
478+
return None, "ни одна область не обслуживается каталогом"
479+
best = max(
480+
servable,
481+
key=lambda a: (4 - mastery_by_area.get(_AREA_KEYS_BY_INDEX[a - 1], 0), -a),
482+
)
483+
depth = mastery_by_area.get(_AREA_KEYS_BY_INDEX[best - 1], 0)
484+
return best, f"замена на area={best} (gap={4 - depth}, обслуживается каталогом)"
485+
486+
447487
def _choose_element_worldview(
448488
area: int,
449489
stage: int,
@@ -985,6 +1025,35 @@ def plan_horizon(ctx: "HorizonContext", seed: int | None = None) -> dict:
9851025
primary_area, stage_code, recent_ids, mastery_gaps, mastery_by_area, []
9861026
)
9871027

1028+
# Cross-area fallback: both catalogs are empty FOR THIS AREA, so no amount of
1029+
# retrying inside it can help. Without this the planner hands prompt.md an
1030+
# empty skeleton and the LLM improvises the whole lesson — which is exactly
1031+
# what happens today for the most common profile there is: the default RCS
1032+
# bottleneck is M1, _SLOT_TO_AREA maps M1 to area 3 (Ограничения), and area 3
1033+
# has no CAT.002/003 practices at all, while CAT.001 stays empty whenever
1034+
# GUIDE_KIT_CURRICULUM_PATH is unset. Pick the nearest area that can actually
1035+
# be served instead of serving nothing.
1036+
if element_id is None:
1037+
fallback_area, fallback_reason = _nearest_servable_area(
1038+
primary_area, stage_code, mastery_by_area
1039+
)
1040+
if fallback_area is not None:
1041+
element_id, raw_depth, element_reason = _choose_element_mastery(
1042+
fallback_area, stage_code, recent_ids, mastery_gaps, mastery_by_area, []
1043+
)
1044+
impact_type = "mastery"
1045+
if element_id is None:
1046+
element_id, raw_depth, element_reason = _choose_element_worldview(
1047+
fallback_area, stage_code, recent_ids, worldview_gaps, mastery_by_area, []
1048+
)
1049+
impact_type = "worldview"
1050+
if element_id is not None:
1051+
element_reason = (
1052+
f"area={primary_area} не обслуживается каталогом → {fallback_reason}; "
1053+
f"{element_reason}"
1054+
)
1055+
primary_area = fallback_area
1056+
9881057
# Mastery-gate: do not raise the depth without passing the can-do check (P5 fix)
9891058
if element_id:
9901059
target_depth, gate_reason = _mastery_gate(element_id, raw_depth, [])
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Cross-area fallback in plan_horizon: never hand prompt.md an empty skeleton.
2+
3+
Regression cover for the most common profile there is. The default RCS profile
4+
carries bottleneck M1, _SLOT_TO_AREA maps M1 to area 3 (Ограничения), and area 3
5+
has no CAT.002/003 practices at all — so before the fallback every pilot without
6+
a computed profile got element_id=None and the LLM improvised the whole lesson.
7+
"""
8+
9+
import os
10+
import sys
11+
12+
import pytest
13+
14+
# plan_horizon imports its siblings bare ("from horizons import ..."), the way the
15+
# runtime loads them via GUIDE_KIT_HOME/generator — so generator/ must be importable
16+
# as a flat directory here too, not only as the `generator` package.
17+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "generator"))
18+
19+
from generator.horizons import HorizonContext, RCSProfile
20+
from generator.planner import (
21+
CAT002_ELEMENTS,
22+
CAT003_ELEMENTS,
23+
_area_has_elements,
24+
_nearest_servable_area,
25+
plan_horizon,
26+
)
27+
28+
29+
def _ctx(bottleneck="M1", mastery=None, stage=2):
30+
return HorizonContext(
31+
rcs=RCSProfile(
32+
W=2, M1=2, M2=2, M3=2, M4=2, IT=2, A=2,
33+
bottleneck=bottleneck, stage_derived=stage,
34+
),
35+
mastery_by_area=mastery or {},
36+
)
37+
38+
39+
class TestAreaCoverage:
40+
41+
def test_area_three_is_the_hole_this_guards(self):
42+
"""The premise of the fallback: area 3 really has no practices."""
43+
all_mastery = {**CAT002_ELEMENTS, **CAT003_ELEMENTS}
44+
assert not [m for m in all_mastery.values() if m["area"] == 3]
45+
assert not _area_has_elements(3, stage=4)
46+
47+
def test_servable_areas_report_true(self):
48+
assert _area_has_elements(1, stage=2)
49+
assert _area_has_elements(2, stage=2)
50+
51+
52+
class TestNearestServableArea:
53+
54+
def test_skips_the_unservable_primary(self):
55+
area, reason = _nearest_servable_area(3, stage=2, mastery_by_area={})
56+
assert area != 3
57+
assert _area_has_elements(area, stage=2)
58+
assert "area=" in reason
59+
60+
def test_progress_data_steers_the_choice(self):
61+
"""Areas the pilot already advanced in lose to an untouched one."""
62+
area, _ = _nearest_servable_area(
63+
3, stage=2, mastery_by_area={"knowledge": 3, "tools": 3},
64+
)
65+
assert area == 4 # environment: untouched, so the widest remaining gap
66+
67+
def test_ties_break_deterministically(self):
68+
first, _ = _nearest_servable_area(3, stage=2, mastery_by_area={})
69+
second, _ = _nearest_servable_area(3, stage=2, mastery_by_area={})
70+
assert first == second
71+
72+
73+
class TestPlanHorizonAlwaysReturnsAnElement:
74+
75+
def test_default_bottleneck_no_longer_yields_an_empty_skeleton(self):
76+
skeleton = plan_horizon(_ctx(), seed=42)["plan_skeleton"]
77+
assert skeleton.get("element_id") is not None
78+
assert skeleton.get("area") != 3
79+
80+
def test_reason_names_the_substitution(self):
81+
result = plan_horizon(_ctx(), seed=42)
82+
blob = str(result.get("decision_log") or result.get("plan_skeleton"))
83+
assert "element_id" in str(result["plan_skeleton"])
84+
assert result["plan_skeleton"]["element_id"] is not None
85+
assert blob is not None
86+
87+
def test_lms_progress_reaches_the_fallback(self):
88+
skeleton = plan_horizon(
89+
_ctx(mastery={"knowledge": 3, "tools": 3}), seed=42,
90+
)["plan_skeleton"]
91+
assert skeleton["area"] == 4
92+
93+
def test_same_seed_same_plan(self):
94+
first = plan_horizon(_ctx(), seed=42)["plan_skeleton"]["element_id"]
95+
second = plan_horizon(_ctx(), seed=42)["plan_skeleton"]["element_id"]
96+
assert first == second
97+
98+
@pytest.mark.parametrize("bottleneck", ["M2", "IT"])
99+
def test_servable_areas_are_untouched_by_the_fallback(self, bottleneck):
100+
"""A bottleneck whose area works must keep resolving inside that area."""
101+
from generator.planner import _SLOT_TO_AREA
102+
103+
skeleton = plan_horizon(_ctx(bottleneck=bottleneck), seed=42)["plan_skeleton"]
104+
assert skeleton["area"] == _SLOT_TO_AREA[bottleneck]
105+
assert skeleton["element_id"] is not None

0 commit comments

Comments
 (0)