Skip to content

Commit 805fa1e

Browse files
Automatic partial rendering
Replace the coarse "spec changed -> re-render the whole affected module" behavior with functionality-level change detection. On re-render, each module's current functional specs are diffed against the set rendered last time and classified (added / removed / edited / moved); rendering resumes from the earliest affected functionality, keeping the unchanged prefix instead of rebuilding from scratch. - Persist the rendered functionality list per module in .codeplain/module_metadata.json, committed per-FRID so an interrupted render still has an accurate baseline (storing raw FR markdown so code-variable FRs are not falsely flagged as edited). - change_detection.py computes the earliest affected FRID; non-functional changes (definitions, impl reqs) and missing baselines fall back to a full re-render. - get_render_choices offers the optimized "render from changed functionality" as the default, alongside full-rebuild and reset options; a functionality appended past the render frontier is treated as a normal continue. - TUI defaults to the fastest (least-work) choice. Remove the obsolete --smart flag. Backward compatible: builds without the new metadata fall back to a full re-render. Covered by tests in test_change_detection.py, test_partial_rendering.py, and test_plain_modules.py.
1 parent 3966572 commit 805fa1e

10 files changed

Lines changed: 1097 additions & 64 deletions

change_detection.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import TYPE_CHECKING, Literal
5+
6+
if TYPE_CHECKING:
7+
from plain_modules import PlainModule
8+
9+
MODULE_FUNCTIONALITIES_KEY = "functionalities"
10+
NON_FUNCTIONAL_SOURCE_HASH_KEY = "non_functional_source_hash"
11+
12+
13+
@dataclass
14+
class FunctionalityChange:
15+
module: str
16+
frid: str
17+
change_type: Literal["added", "removed", "edited", "moved"]
18+
detail: str | None = None
19+
20+
21+
@dataclass
22+
class PartialRenderStart:
23+
module: "PlainModule"
24+
frid: str
25+
26+
27+
def determine_partial_render_start(plain_module: "PlainModule") -> PartialRenderStart | None:
28+
"""Determine where to start partial rendering based on spec changes.
29+
30+
Returns None (only full render is safe) if non-FR sections changed
31+
(e.g. definitions, implementation reqs) since previously-rendered FRs
32+
were generated without that context, if no changes are found, or if
33+
all changes are trailing removals that don't require rendering.
34+
"""
35+
all_modules = plain_module.all_required_modules + [plain_module]
36+
37+
for module in all_modules:
38+
if _non_functional_content_changed(module):
39+
return None
40+
41+
changes = _detect_module_changes(module)
42+
if not changes:
43+
continue
44+
45+
current_fr_count = len(module._get_module_functional_requirements())
46+
earliest_frid = _get_earliest_affected_frid(changes, current_fr_count)
47+
if earliest_frid is None:
48+
continue
49+
return PartialRenderStart(module=module, frid=earliest_frid)
50+
51+
return None
52+
53+
54+
def _non_functional_content_changed(module: "PlainModule") -> bool:
55+
"""Check whether anything outside functional specs changed since last render.
56+
57+
A missing stored hash (older builds) is treated as changed — partial rendering
58+
is unsafe without a known baseline.
59+
"""
60+
metadata = module.load_module_metadata()
61+
if not metadata:
62+
return False
63+
stored_hash = metadata.get(NON_FUNCTIONAL_SOURCE_HASH_KEY)
64+
if stored_hash is None:
65+
return True
66+
return stored_hash != module.get_module_non_functional_source_hash()
67+
68+
69+
def _get_earliest_affected_frid(changes: list[FunctionalityChange], current_fr_count: int) -> str | None:
70+
"""Earliest FRID (in current spec numbering) that must be re-rendered.
71+
72+
Returns the minimum position across all changes:
73+
- added / edited: the FRID itself.
74+
- removed: the position the removal opened up (now occupied by the next FR).
75+
- moved: the FR's old position.
76+
77+
Correctness does not rely on any single change type's position being individually
78+
"the" earliest (in particular, a move's old position is *not* always its earliest
79+
touched position once moves mix with adds/removes). The guarantee is structural:
80+
the first index at which the new spec diverges from the old is always emitted as
81+
some change anchored at that index — a move with that old position, or an
82+
edit/removal/addition there — so the minimum over all change FRIDs lands at or
83+
before the true first divergence. Rendering runs from this FRID to the end of the
84+
module, so an at-or-before start point is always safe (it can re-render unchanged
85+
trailing FRs, but never skips a changed one).
86+
87+
Returns None when every change is a removal beyond the current spec length
88+
(only trailing FRs were removed, so nothing needs rendering).
89+
"""
90+
earliest = None
91+
for change in changes:
92+
frid_int = int(change.frid)
93+
if change.change_type == "removed" and frid_int > current_fr_count:
94+
continue
95+
if earliest is None or frid_int < earliest:
96+
earliest = frid_int
97+
if earliest is None:
98+
return None
99+
return str(earliest)
100+
101+
102+
def _detect_module_changes(module: "PlainModule") -> list[FunctionalityChange]:
103+
metadata = module.load_module_metadata()
104+
old_frs: list[str] = metadata.get(MODULE_FUNCTIONALITIES_KEY, []) if metadata else []
105+
new_frs: list[str] = module._get_module_functional_requirements()
106+
107+
if old_frs == new_frs:
108+
return []
109+
110+
moves, edits, removed, added = _classify_changes(old_frs, new_frs)
111+
112+
changes: list[FunctionalityChange] = []
113+
name = module.module_name
114+
115+
for old_idx, new_idx in moves:
116+
old_frid = _frid_from_index(old_idx)
117+
new_frid = _frid_from_index(new_idx)
118+
changes.append(FunctionalityChange(module=name, frid=old_frid, change_type="moved", detail=new_frid))
119+
120+
for idx in edits:
121+
changes.append(FunctionalityChange(module=name, frid=_frid_from_index(idx), change_type="edited"))
122+
123+
for idx in removed:
124+
changes.append(FunctionalityChange(module=name, frid=_frid_from_index(idx), change_type="removed"))
125+
126+
for idx in added:
127+
changes.append(FunctionalityChange(module=name, frid=_frid_from_index(idx), change_type="added"))
128+
129+
return changes
130+
131+
132+
def _classify_changes(
133+
old_frs: list[str], new_frs: list[str]
134+
) -> tuple[list[tuple[int, int]], list[int], list[int], list[int]]:
135+
matched_old: set[int] = set()
136+
matched_new: set[int] = set()
137+
138+
for i in range(min(len(old_frs), len(new_frs))):
139+
if old_frs[i] == new_frs[i]:
140+
matched_old.add(i)
141+
matched_new.add(i)
142+
143+
content_matches: list[tuple[int, int]] = []
144+
for old_idx in range(len(old_frs)):
145+
if old_idx in matched_old:
146+
continue
147+
for new_idx in range(len(new_frs)):
148+
if new_idx in matched_new:
149+
continue
150+
if old_frs[old_idx] == new_frs[new_idx]:
151+
content_matches.append((old_idx, new_idx))
152+
matched_old.add(old_idx)
153+
matched_new.add(new_idx)
154+
break
155+
156+
moves: list[tuple[int, int]] = []
157+
if content_matches and _has_relative_order_change(content_matches):
158+
moves = content_matches
159+
160+
edits: list[int] = []
161+
for i in range(min(len(old_frs), len(new_frs))):
162+
if i not in matched_old and i not in matched_new:
163+
edits.append(i)
164+
matched_old.add(i)
165+
matched_new.add(i)
166+
167+
removed = [i for i in range(len(old_frs)) if i not in matched_old]
168+
added = [i for i in range(len(new_frs)) if i not in matched_new]
169+
170+
return moves, edits, removed, added
171+
172+
173+
def _has_relative_order_change(matches: list[tuple[int, int]]) -> bool:
174+
"""Check if content matches represent a true reorder (relative order changed).
175+
176+
If all matches preserve relative order (sorted by old_idx gives same ordering
177+
as sorted by new_idx), it's just a positional shift from insertions/removals.
178+
"""
179+
if len(matches) <= 1:
180+
return False
181+
sorted_by_old = sorted(matches, key=lambda m: m[0])
182+
new_indices = [m[1] for m in sorted_by_old]
183+
for i in range(len(new_indices) - 1):
184+
if new_indices[i] > new_indices[i + 1]:
185+
return True
186+
return False
187+
188+
189+
def _frid_from_index(index: int) -> str:
190+
return str(index + 1)

0 commit comments

Comments
 (0)