oc_chat
Loading...
Searching...
No Matches
derive_verify.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/regenerate/derive_verify.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/regenerate/test_derive_verify.py"
6"""
7Version status / lineage / bump_kind derivation + the step-7 derive-verify.
8
9Purpose
10-------
11Derive the *derived* ``roadmap_versions`` columns (``status``, ``parent_/
12supersedes_/superseded_by_version_id``, ``bump_kind``, ``one-current-per-era``)
13from the ordered changelog — pinned to **numeric-per-field** version ordering
14(lexical forbidden, §3.2) — and recompute-and-verify them against the built
15store at regenerate step-7 BEFORE the publish/swap/hash (the publish-gate;
16AC §8.26/32/37, §5.3.1 ``derive_verify_rule``).
17
18Responsibilities
19----------------
20- ``derive_versions(versions)`` — the deterministic per-era derivation (used by
21 the builder to insert AND by the verify to compare).
22- ``verify_versions(conn, versions)`` — re-derive + hard-fail on any mismatch.
23
24Diagnostics
25-----------
26Domain: OC_CHAT.MAIN
27Levels:
28 L2 - derivation lifecycle
29 L3 - per-era ordering/selection
30 L4 - per-version lineage
31
32Contracts
33---------
34- Ordering is numeric-per-field (major int, then 2-digit minor) — a consistent
35 cross-major boundary (v9.99 vs v10.00) is numeric-correct, never lexical.
36- Highest non-superseded per era = ``current`` (exactly one per era).
37
38Dependencies
39------------
40- oc_diagnostics (structured debug logging via _dbg)
41- option_c_registry_contracts (the version regex constants)
42- oc_chat.registry.regenerate.envelope (RegenerateError with REGEN_BUILD_FAILED on a derive-verify mismatch)
43"""
44from __future__ import annotations
45
46import re
47import sqlite3
48from typing import Any, Optional
49
50from oc_diagnostics import _dbg
51
52from option_c_registry_contracts import ROADMAP_VERSION_RE
53
54from oc_chat.registry.regenerate.envelope import REGEN_BUILD_FAILED, RegenerateError
55
56_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
57
58_ROADMAP_VERSION_PAT = re.compile(ROADMAP_VERSION_RE)
59
60
61def _parse_version(version_id: str) -> Optional[tuple[int, int]]:
62 """Return ``(major, minor)`` for a roadmap-axis ``vN.NN``, else ``None``."""
63 if not _ROADMAP_VERSION_PAT.fullmatch(version_id):
64 return None # product SemVer → no roadmap_versions row (§3.2)
65 major_str, minor_str = version_id[1:].split(".", 1)
66 return int(major_str), int(minor_str)
67
68
70 current: tuple[int, int], predecessor: Optional[tuple[int, int]], override: Optional[str]
71) -> str:
72 if override is not None:
73 return "out_of_round_revision"
74 if predecessor is None:
75 return "major" # first version of an era (§3.2)
76 cmajor, cminor = current
77 pmajor, pminor = predecessor
78 if cmajor != pmajor:
79 return "major"
80 if cminor // 10 != pminor // 10:
81 return "minor" # crossed a 10-unit boundary (e.g. v3.09 -> v3.10)
82 return "patch"
83
84
85def derive_versions(versions: list) -> dict[str, dict[str, Any]]:
86 """Derive status/lineage/bump_kind per roadmap-axis version (numeric ordering).
87
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).
91 """
92 by_era: dict[str, list] = {}
93 override_by_id: dict[str, Optional[str]] = {}
94 for version in versions:
95 parsed = _parse_version(version.version)
96 if parsed is None:
97 continue
98 by_era.setdefault(version.era, []).append((parsed, version.version))
99 override_by_id[version.version] = version.bump_override
100
101 derived: dict[str, dict[str, Any]] = {}
102 for _era, members in by_era.items():
103 members.sort(key=lambda entry: entry[0]) # numeric-per-field (R17-1)
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]),
116 }
117 return derived
118
119
120def dedup_version_records(versions: list) -> list:
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.
126 """
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)
133 continue
134 current = by_id[version.version]
135 updates = {
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
139 }
140 if updates:
141 by_id[version.version] = current.model_copy(update=updates)
142 return [by_id[vid] for vid in order]
143
144
145_DERIVED_COLUMNS = (
146 "status",
147 "parent_version_id",
148 "supersedes_version_id",
149 "superseded_by_version_id",
150 "bump_kind",
151)
152
153
154def verify_versions(conn: sqlite3.Connection, versions: list) -> None:
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"
158 # @invariant: inv.version_lineage_derived (§5.3.1 D derive_verify_rule — AC §8.26/§8.32;
159 # status/lineage C3–C6 + one-current-per-era + bump_kind derived then verified before USE)
160 derived = derive_versions(versions)
161 conn.row_factory = sqlite3.Row
162 stored = {
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"
167 )
168 }
169 if set(stored) != set(derived):
170 raise RegenerateError(
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)),
175 )
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]:
180 raise RegenerateError(
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",
186 )
187 _dbg("OC_CHAT.MAIN", func, f"verified {len(derived)} versions", 3)
str _bump_kind(tuple[int, int] current, Optional[tuple[int, int]] predecessor, Optional[str] override)
None verify_versions(sqlite3.Connection conn, list versions)
Optional[tuple[int, int]] _parse_version(str version_id)
dict[str, dict[str, Any]] derive_versions(list versions)