62 """Return ``(major, minor)`` for a roadmap-axis ``vN.NN``, else ``None``."""
63 if not _ROADMAP_VERSION_PAT.fullmatch(version_id):
65 major_str, minor_str = version_id[1:].split(
".", 1)
66 return int(major_str), int(minor_str)
70 current: tuple[int, int], predecessor: Optional[tuple[int, int]], override: Optional[str]
72 if override
is not None:
73 return "out_of_round_revision"
74 if predecessor
is None:
76 cmajor, cminor = current
77 pmajor, pminor = predecessor
80 if cminor // 10 != pminor // 10:
86 """Derive status/lineage/bump_kind per roadmap-axis version (numeric ordering).
88 Returns ``{version_id: {status, parent_version_id, supersedes_version_id,
89 superseded_by_version_id, bump_kind}}``. Product-SemVer records are excluded
90 (they create no ``roadmap_versions`` row, §3.2).
92 by_era: dict[str, list] = {}
93 override_by_id: dict[str, Optional[str]] = {}
94 for version
in versions:
98 by_era.setdefault(version.era, []).append((parsed, version.version))
99 override_by_id[version.version] = version.bump_override
101 derived: dict[str, dict[str, Any]] = {}
102 for _era, members
in by_era.items():
103 members.sort(key=
lambda entry: entry[0])
104 ordered_ids = [version_id
for _parsed, version_id
in members]
105 for index, (parsed, version_id)
in enumerate(members):
106 predecessor_id = ordered_ids[index - 1]
if index > 0
else None
107 successor_id = ordered_ids[index + 1]
if index + 1 < len(members)
else None
108 predecessor_parsed = members[index - 1][0]
if index > 0
else None
109 is_current = index == len(members) - 1
110 derived[version_id] = {
111 "status":
"current" if is_current
else "superseded",
112 "parent_version_id": predecessor_id,
113 "supersedes_version_id": predecessor_id,
114 "superseded_by_version_id": successor_id,
115 "bump_kind":
_bump_kind(parsed, predecessor_parsed, override_by_id[version_id]),
121 """Collapse records sharing a ``version_id`` into one (a same-version
122 roadmap + product changelog pair maps to exactly ONE ``roadmap_versions``
123 row — §3.2 / AC §8.38). Required metadata is agreement-checked in the step-6
124 cross-record pass; here we coalesce the omit-defers-to-present optionals
125 (``round``/``bump_override``/``notes``). Insertion order is preserved.
127 by_id: dict[str, Any] = {}
128 order: list[str] = []
129 for version
in versions:
130 if version.version
not in by_id:
131 by_id[version.version] = version
132 order.append(version.version)
134 current = by_id[version.version]
136 field: getattr(version, field)
137 for field
in (
"round",
"bump_override",
"notes")
138 if getattr(current, field)
is None and getattr(version, field)
is not None
141 by_id[version.version] = current.model_copy(update=updates)
142 return [by_id[vid]
for vid
in order]
155 """Re-derive + hard-fail (``REGEN_BUILD_FAILED``) on any stored-vs-derived
156 mismatch — the step-7 publish-gate (AC §8.26, ``test_version_status_derive_mismatch``)."""
157 func =
"verify_versions"
161 conn.row_factory = sqlite3.Row
163 row[
"version_id"]: row
164 for row
in conn.execute(
165 "SELECT version_id, status, parent_version_id, supersedes_version_id, "
166 "superseded_by_version_id, bump_kind FROM roadmap_versions"
169 if set(stored) != set(derived):
171 "derive-verify: stored roadmap_versions set != derived set "
172 f
"(stored {sorted(stored)}, derived {sorted(derived)})",
173 code=REGEN_BUILD_FAILED,
174 records=sorted(set(stored) ^ set(derived)),
176 for version_id, expected
in derived.items():
177 row = stored[version_id]
178 for column
in _DERIVED_COLUMNS:
179 if row[column] != expected[column]:
181 f
"derive-verify: {version_id} {column} stored={row[column]!r} "
182 f
"derived={expected[column]!r}",
183 code=REGEN_BUILD_FAILED,
184 records=[version_id],
185 hint=
"the built store's derived version column does not match source",
187 _dbg(
"OC_CHAT.MAIN", func, f
"verified {len(derived)} versions", 3)