PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
audit_subsystem_lifecycle.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Enforce the documentation obligations each subsystem lifecycle status carries."""
3
4from __future__ import annotations
5
6import json
7import re
8import sys
9from pathlib import Path
10
11
12REPO_ROOT = Path(__file__).resolve().parents[2]
13RECORDS = REPO_ROOT / "tests" / "tooling" / "subsystem_records.json"
14PAGE_TYPES = REPO_ROOT / "tests" / "tooling" / "page_types.json"
15FAMILIES = REPO_ROOT / "tests" / "tooling" / "capability_families.json"
16PAGE_DIRS = ("docs/pages", "docs")
17
18# The ladder is cumulative: claiming a rung owes everything below it too. This is
19# the whole point of the gate - experimental work is never blocked by documentation
20# it cannot honestly write yet, but it also cannot skip a rung by claiming the top.
21LADDER = ("planned", "internal", "experimental", "supported")
22
23LADDER_OBLIGATIONS = {
24 "planned": ("purpose", "intended_scope", "design_owner", "not_implemented_status"),
25 "internal": ("scope_boundary", "architecture_boundary", "dependencies",
26 "developer_entry_points"),
27 "experimental": ("configuration", "selection_guidance", "observability", "limitations",
28 "safe_use_boundaries"),
29 "supported": ("evidence", "lifecycle_and_restart", "operations", "troubleshooting",
30 "examples", "complete_reference"),
31}
32
33# Statuses off the ladder. Each owes its own set; those that describe something that
34# was once built also owe the ladder up to the peak status it reached.
35TERMINAL_OBLIGATIONS = {
36 "known-defective": ("defect_disclosure", "defect_scope_record", "safe_use_boundaries",
37 "limitations"),
38 "deprecated": ("migration_path", "replacement", "compatibility_period", "removal_policy"),
39 "removed": ("history_record", "rejection_behavior"),
40}
41# Obligations that make sense only while the subsystem is unbuilt. A supported
42# subsystem does not owe a not-implemented notice.
43NON_INHERITED = ("not_implemented_status",)
44NEEDS_PEAK = ("known-defective", "deprecated", "removed")
45# `removed` documents an absence, so it does not owe the prose of what it replaced.
46INHERITS_LADDER = ("known-defective", "deprecated")
47
48VALID_STATUSES = tuple(LADDER) + tuple(TERMINAL_OBLIGATIONS)
49
50# Which status may follow which. Absent from a row means the transition is invalid:
51# a subsystem cannot reach `removed` without first being deprecated or declared
52# defective, and cannot be revived once removed.
53#
54# `supported -> experimental` is allowed, but only with a `demotion_reason`
55# (see DEMOTIONS): a supported claim that turns out to rest on less verification than
56# it implied is withdrawn openly, not left standing because no defect was found.
57#
58# `known-defective -> planned` is allowed on the same terms, and means something
59# narrower than a demotion: a subsystem declared defective that turns out never to have
60# been implemented at all, whose dead hooks have since been removed. The reason says what
61# was found and what came out, so the record does not read as a working feature that
62# broke.
63#
64# `planned -> removed` is the one deliberate exception, and it is not a removal in the
65# same sense: nothing was ever built, so a cancelled design owes a history record and
66# a rejection behaviour rather than a migration path. Every other route to `removed`
67# passes through `deprecated` or `known-defective`.
68TRANSITIONS = {
69 None: set(VALID_STATUSES) - {"removed"},
70 "planned": {"planned", "internal", "experimental", "supported", "removed"},
71 "internal": {"internal", "experimental", "supported", "known-defective", "deprecated"},
72 "experimental": {"experimental", "supported", "known-defective", "deprecated"},
73 "supported": {"supported", "experimental", "known-defective", "deprecated"},
74 "known-defective": {"known-defective", "planned", "experimental", "supported", "deprecated", "removed"},
75 "deprecated": {"deprecated", "removed"},
76 "removed": {"removed"},
77}
78
79# Transitions down the ladder that must say why. A demotion is not a defect: nothing
80# may be wrong with the code, only with the evidence the earlier claim rested on.
81# `known-defective -> planned` withdraws a feature that was never implemented.
82DEMOTIONS = {("supported", "experimental"), ("known-defective", "planned")}
83
84VALID_VISIBILITY = ("internal", "public")
85
86# The concern vocabulary of 64_Documentation_Extension_Framework section 5.
87VALID_CONCERNS = (
88 "numerical_method", "user_selector", "units_and_nondimensionalization",
89 "artifact_topology", "persistent_restart_state", "determinism_and_reproducibility",
90 "mpi_distributed_execution", "external_service", "security_credentials",
91 "generated_artifact", "file_format", "destructive_scope", "backward_compatibility",
92 "scientific_verification",
93)
94
95RECORD_KEYS = {"id", "title", "status", "visibility", "previous_status", "peak_status",
96 "proposed_status", "promotion_rationale", "demotion_reason",
97 "capability_families",
98 "obligations", "concerns", "note"}
99# A reason short enough to fit here is an evasion, not a reason.
100MIN_REASON_CHARS = 30
101FILLER_REASONS = {"n/a", "na", "none", "not applicable", "no", "-", "tbd"}
102
103
104def page_index() -> dict:
105 """!
106 @brief Every documentation page id with the anchors it defines.
107 @return Mapping of page id to the set of `@section`, `@subsection`, and `@anchor` names.
108 """
109 index: dict = {}
110 for directory in PAGE_DIRS:
111 for markdown in sorted((REPO_ROOT / directory).glob("*.md")):
112 text = markdown.read_text(encoding="utf-8")
113 match = re.search(r"^@page\s+(\S+)", text, re.M)
114 if not match or match.group(1).startswith("<"):
115 continue
116 anchors = set(re.findall(r"^@(?:section|subsection|subsubsection|anchor)\s+(\S+)",
117 text, re.M))
118 index[match.group(1)] = anchors
119 return index
120
121
122def required_obligations(record: dict) -> tuple:
123 """!
124 @brief The obligation ids a record owes.
125
126 @details A record may declare a `proposed_status` above its claimed one. That is
127 how a subsystem documented to a higher bar waits for the owner to decide
128 whether it has actually earned it: obligations are checked against the
129 proposal, so the writing is held to the higher standard, while the status
130 the documentation publishes stays conservative until a human agrees.
131 @param[in] record One subsystem record.
132 @return Ordered tuple of obligation ids.
133 """
134 status = record.get("proposed_status") or record.get("status")
135 owed: list = []
136 if status in LADDER:
137 for rung in LADDER[: LADDER.index(status) + 1]:
138 owed.extend(LADDER_OBLIGATIONS[rung])
139 else:
140 if status in INHERITS_LADDER:
141 peak = record.get("peak_status")
142 if peak in LADDER:
143 for rung in LADDER[: LADDER.index(peak) + 1]:
144 owed.extend(LADDER_OBLIGATIONS[rung])
145 owed.extend(TERMINAL_OBLIGATIONS.get(status, ()))
146 if status != "planned":
147 owed = [key for key in owed if key not in NON_INHERITED]
148 # A status may re-owe something a lower rung already required; keep it once.
149 return tuple(dict.fromkeys(owed))
150
151
152def check_satisfaction(context: str, key: str, spec, pages: dict, published: set,
153 public: bool) -> list:
154 """!
155 @brief Verify one obligation or concern is genuinely answered.
156
157 @details An answer is a documentation reference that resolves, or a stated reason
158 for non-applicability, or a literal value where the obligation is a fact
159 rather than prose. An empty or filler answer is a violation.
160 @param[in] context Record id, for messages.
161 @param[in] key Obligation or concern id.
162 @param[in] spec The declared answer.
163 @param[in] pages Page index from page_index().
164 @param[in] published Ids of the pages the site publishes.
165 @param[in] public Whether the subsystem is publicly visible.
166 @return List of violation strings.
167 """
168 if not isinstance(spec, dict):
169 return [f"{context}: '{key}' must be an object, got {type(spec).__name__}"]
170 problems = []
171 if "not_applicable" in spec:
172 reason = str(spec["not_applicable"]).strip()
173 if reason.lower().rstrip(".") in FILLER_REASONS or len(reason) < MIN_REASON_CHARS:
174 problems.append(
175 f"{context}: '{key}' is declared not applicable without a stated reason "
176 f"({reason!r}). A reasoned N/A is accepted; a bare one is not"
177 )
178 return problems
179 if "value" in spec:
180 if not str(spec["value"]).strip():
181 problems.append(f"{context}: '{key}' declares an empty value")
182 return problems
183 page = spec.get("page")
184 if not page:
185 problems.append(
186 f"{context}: '{key}' is unsatisfied - give it a page reference, a value, or a "
187 f"reasoned not_applicable"
188 )
189 return problems
190 if page not in pages:
191 problems.append(f"{context}: '{key}' cites page '{page}', which does not exist")
192 return problems
193 anchor = spec.get("anchor")
194 if anchor and anchor not in pages[page]:
195 problems.append(
196 f"{context}: '{key}' cites anchor '{anchor}' on page '{page}', which does not "
197 f"define it"
198 )
199 if public and page not in published:
200 problems.append(
201 f"{context}: '{key}' cites '{page}', which is not a published page, but the "
202 f"subsystem is publicly visible"
203 )
204 return problems
205
206
207def validate(records: list, pages: dict, families: dict, published: set) -> list:
208 """!
209 @brief Validate every subsystem record against the lifecycle contract.
210 @param[in] records Subsystem records.
211 @param[in] pages Page index from page_index().
212 @param[in] families Capability family metadata, keyed by family id.
213 @param[in] published Ids of the pages the site publishes.
214 @return List of violation strings; empty means the contract holds.
215 """
216 problems: list = []
217 seen: set = set()
218 for record in records:
219 identifier = record.get("id")
220 if not identifier:
221 problems.append("a record declares no id")
222 continue
223 if identifier in seen:
224 problems.append(f"{identifier}: declared more than once")
225 seen.add(identifier)
226
227 unknown = set(record) - RECORD_KEYS
228 if unknown:
229 problems.append(f"{identifier}: unknown field(s) {sorted(unknown)}")
230
231 status = record.get("status")
232 if status not in VALID_STATUSES:
233 problems.append(
234 f"{identifier}: status {status!r} is not one of {list(VALID_STATUSES)}"
235 )
236 continue
237
238 visibility = record.get("visibility")
239 if visibility not in VALID_VISIBILITY:
240 problems.append(
241 f"{identifier}: visibility {visibility!r} is not one of "
242 f"{list(VALID_VISIBILITY)}"
243 )
244 continue
245 if status == "internal" and visibility != "internal":
246 problems.append(
247 f"{identifier}: status 'internal' contradicts visibility 'public'; a "
248 f"subsystem the user can reach is at least experimental"
249 )
250
251 previous = record.get("previous_status", None)
252 if previous is not None and previous not in VALID_STATUSES:
253 problems.append(f"{identifier}: previous_status {previous!r} is not a valid status")
254 elif status not in TRANSITIONS[previous]:
255 problems.append(
256 f"{identifier}: {previous or 'a new record'} -> '{status}' is not a valid "
257 f"lifecycle transition (allowed: {sorted(TRANSITIONS[previous])})"
258 )
259
260 reason = record.get("demotion_reason")
261 if (previous, status) in DEMOTIONS:
262 text = (reason or "").strip()
263 if text.lower().rstrip(".") in FILLER_REASONS or len(text) < MIN_REASON_CHARS:
264 problems.append(
265 f"{identifier}: {previous} -> '{status}' is a demotion and needs a "
266 f"demotion_reason saying what the earlier claim did not establish"
267 )
268 elif reason is not None:
269 problems.append(
270 f"{identifier}: demotion_reason applies only to a demotion "
271 f"({sorted(DEMOTIONS)}), not {previous or 'a new record'} -> '{status}'"
272 )
273
274 proposed = record.get("proposed_status")
275 if proposed is not None:
276 if proposed not in LADDER:
277 problems.append(
278 f"{identifier}: proposed_status {proposed!r} must name a rung of "
279 f"{list(LADDER)}"
280 )
281 elif status not in LADDER or LADDER.index(proposed) <= LADDER.index(status):
282 problems.append(
283 f"{identifier}: proposed_status '{proposed}' is not above the claimed "
284 f"status '{status}'; a proposal that is not a promotion is noise"
285 )
286 elif not record.get("promotion_rationale"):
287 problems.append(
288 f"{identifier}: proposes '{proposed}' but gives no promotion_rationale. "
289 f"Say what the owner is being asked to confirm"
290 )
291
292 if status in NEEDS_PEAK:
293 peak = record.get("peak_status")
294 if peak not in LADDER:
295 problems.append(
296 f"{identifier}: status '{status}' requires peak_status naming the highest "
297 f"rung it reached, one of {list(LADDER)}"
298 )
299 elif record.get("peak_status") is not None:
300 problems.append(
301 f"{identifier}: peak_status applies only to {list(NEEDS_PEAK)}, not '{status}'"
302 )
303
304 obligations = record.get("obligations") or {}
305 owed = required_obligations(record)
306 for key in owed:
307 if key not in obligations:
308 problems.append(
309 f"{identifier}: status '{status}' owes '{key}', which is not declared"
310 )
311 else:
312 problems.extend(
313 check_satisfaction(identifier, key, obligations[key], pages, published,
314 visibility == "public")
315 )
316 for key in sorted(set(obligations) - set(owed)):
317 problems.append(
318 f"{identifier}: declares obligation '{key}', which status '{status}' does not "
319 f"owe; remove it or claim the status that owes it"
320 )
321
322 concerns = record.get("concerns") or {}
323 for key in sorted(concerns):
324 if key not in VALID_CONCERNS:
325 problems.append(
326 f"{identifier}: concern '{key}' is not in the concern vocabulary of "
327 f"64_Documentation_Extension_Framework"
328 )
329 continue
330 problems.extend(
331 check_satisfaction(identifier, key, concerns[key], pages, published,
332 visibility == "public")
333 )
334
335 # A planned subsystem must not already be presented as working behavior.
336 for family_id in record.get("capability_families", []):
337 family = families.get(family_id)
338 if family is None:
339 problems.append(
340 f"{identifier}: cites capability family '{family_id}', which is not "
341 f"registered"
342 )
343 continue
344 if status != "planned":
345 continue
346 live = sorted(
347 name for name, meta in family.get("value_metadata", {}).items()
348 if (meta or {}).get("status") == "supported"
349 )
350 if live:
351 problems.append(
352 f"{identifier}: is 'planned' but family '{family_id}' already offers "
353 f"supported value(s) {live}; a planned subsystem must not appear as "
354 f"supported behavior"
355 )
356 return problems
357
358
359def validate_value_ownership(records: list, families: dict) -> list:
360 """!
361 @brief Check that every capability value has an owning subsystem it does not outrank.
362
363 @details A value's status and its subsystem's status used to be independent axes,
364 which let a value read `supported` under a subsystem still `experimental`:
365 the Newton-Krylov solver value, the periodic handlers, and every storage
366 policy all did. The subsystem status is now a ceiling. The rules need the
367 whole registry, so they are kept out of the per-record `validate()`:
368
369 - every registered family is listed by at least one subsystem record;
370 - a family listed by several subsystems names each canonical value's owner
371 in `value_metadata[*].subsystem`, and that owner must list the family;
372 - a value may not claim a ladder rung above its owner's, and under an owner
373 that is off the ladder it must share the owner's status or be removed;
374 - `off_switch: true` exempts the single value per family that disables
375 the owning subsystem, since selecting it claims nothing about it.
376
377 Accepted spellings and deprecated aliases inherit their target's owner and
378 are not checked separately.
379 @param[in] records Every subsystem record.
380 @param[in] families Capability family metadata, keyed by family id.
381 @return List of violation strings; empty means every value sits under its owner.
382 """
383 problems: list = []
384 status_of = {record.get("id"): record.get("status") for record in records}
385 listed_by: dict = {}
386 for record in records:
387 for family_id in record.get("capability_families", []):
388 listed_by.setdefault(family_id, []).append(record.get("id"))
389
390 for family_id, family in sorted(families.items()):
391 owners = listed_by.get(family_id, [])
392 if not owners:
393 problems.append(
394 f"{family_id}: no subsystem lists this family, so its values have no "
395 f"lifecycle above them; add it to the owning record's capability_families"
396 )
397 continue
398 off_switches = []
399 for name, meta in sorted(family.get("value_metadata", {}).items()):
400 meta = meta or {}
401 if meta.get("spelling_of") or meta.get("alias_of"):
402 continue
403 owner = meta.get("subsystem")
404 if owner is None:
405 if len(owners) > 1:
406 problems.append(
407 f"{family_id}: '{name}' belongs to a family listed by {sorted(owners)}; "
408 f"name its owner in value_metadata.subsystem"
409 )
410 continue
411 owner = owners[0]
412 elif owner not in owners:
413 problems.append(
414 f"{family_id}: '{name}' names subsystem '{owner}', which does not list "
415 f"this family"
416 )
417 continue
418 if "off_switch" in meta:
419 if meta["off_switch"] is not True:
420 problems.append(
421 f"{family_id}: '{name}' declares off_switch {meta['off_switch']!r}; "
422 f"it is either true or absent"
423 )
424 off_switches.append(name)
425 continue
426 owner_status = status_of.get(owner)
427 value_status = meta.get("status")
428 if value_status in LADDER and owner_status in LADDER:
429 if LADDER.index(value_status) > LADDER.index(owner_status):
430 problems.append(
431 f"{family_id}: '{name}' claims '{value_status}' but its subsystem "
432 f"'{owner}' is '{owner_status}'; a value cannot outrank the "
433 f"subsystem it belongs to"
434 )
435 elif owner_status not in LADDER and value_status not in (owner_status, "removed"):
436 problems.append(
437 f"{family_id}: '{name}' is '{value_status}' under '{owner}', which is "
438 f"'{owner_status}'; under an owner off the ladder a value shares its "
439 f"status or is removed"
440 )
441 if len(off_switches) > 1:
442 problems.append(
443 f"{family_id}: {off_switches} are all marked off_switch; a family has at "
444 f"most one value that disables its subsystem"
445 )
446 return problems
447
448
449def main() -> int:
450 """!
451 @brief Report subsystem lifecycle violations.
452 @return Process status code.
453 """
454 document = json.loads(RECORDS.read_text(encoding="utf-8"))
455 records = document["subsystems"]
456 published = set(json.loads(PAGE_TYPES.read_text(encoding="utf-8"))["assignments"])
457 families = {
458 family["id"]: family
459 for family in json.loads(FAMILIES.read_text(encoding="utf-8"))["families"]
460 }
461 problems = validate(records, page_index(), families, published)
462 problems += validate_value_ownership(records, families)
463 if problems:
464 print("Subsystem lifecycle violations:", file=sys.stderr)
465 for problem in problems:
466 print(f" {problem}", file=sys.stderr)
467 print(
468 "\nObligations grow with the status a subsystem claims. See\n"
469 "64_Documentation_Extension_Framework section 4. Lower the claimed status, or\n"
470 "write the documentation that status owes - do not add empty prose to pass.",
471 file=sys.stderr,
472 )
473 return 1
474
475 from collections import Counter
476 spread = Counter(record["status"] for record in records)
477 summary = ", ".join(f"{count} {status}" for status, count in sorted(spread.items()))
478 print(f"Subsystem lifecycle audit passed: {len(records)} subsystem(s) ({summary}).")
479 proposals = [r for r in records if r.get("proposed_status")]
480 if proposals:
481 print(f"\n{len(proposals)} promotion(s) awaiting the owner's decision. The "
482 f"documentation meets the higher bar; whether the subsystem has earned "
483 f"the status is a human judgement this gate cannot make:")
484 for record in proposals:
485 print(f" {record['id']}: {record['status']} -> {record['proposed_status']}")
486 print(f" {record['promotion_rationale']}")
487 return 0
488
489
490if __name__ == "__main__":
491 raise SystemExit(main())
dict page_index()
Every documentation page id with the anchors it defines.
list check_satisfaction(str context, str key, spec, dict pages, set published, bool public)
Verify one obligation or concern is genuinely answered.
list validate_value_ownership(list records, dict families)
Check that every capability value has an owning subsystem it does not outrank.
tuple required_obligations(dict record)
The obligation ids a record owes.
int main()
Report subsystem lifecycle violations.
list validate(list records, dict pages, dict families, set published)
Validate every subsystem record against the lifecycle contract.