23def normalize(paths: list, workspace: Path, run_id: str) -> list:
25 @brief Replace unstable path components with logical tokens.
27 The workspace root, the generated run id, and any embedded timestamp differ on
28 every invocation. Fingerprinting the raw plan would therefore report drift on
29 every run; fingerprinting the normalized shape reports drift only when the
30 topology actually changes.
31 @param[in] paths Planned artifact paths.
32 @param[in] workspace Temporary workspace root used for the plan.
33 @param[in] run_id Generated run identifier.
34 @return Sorted, normalized artifact tokens.
39 text = text.replace(str(workspace),
"<workspace>")
40 text = text.replace(run_id,
"<run_id>")
41 text = re.sub(
r"\d{8}-\d{6}",
"<timestamp>", text)
43 return sorted(normalized)
46def run_plan(workspace: Path, case_dir: Path, monitor: str, extra: list,
47 post: str =
"post.yml") -> dict:
49 @brief Invoke the dry-run planner for one scenario and return its raw plan.
50 @param[in] workspace Scenario workspace.
51 @param[in] case_dir Directory holding the initialized case.
52 @param[in] monitor Monitor file name inside the case directory.
53 @param[in] extra Additional CLI arguments for the scenario.
54 @param[in] post Post recipe file name inside the case directory.
55 @return Parsed plan mapping.
56 @throws RuntimeError when the planner fails.
58 result = subprocess.run(
60 sys.executable, str(PICURV),
"run",
"--dry-run",
"--format",
"json",
61 "--case", str(case_dir /
"config" /
"case.yml"),
62 "--solver", str(case_dir /
"config" /
"solver.yml"),
63 "--monitor", str(case_dir /
"config" / monitor),
64 "--post", str(case_dir /
"config" / post),
67 cwd=workspace, capture_output=
True, text=
True, check=
False, timeout=180,
69 if result.returncode != 0:
70 raise RuntimeError(f
"dry-run failed:\n{result.stdout}\n{result.stderr}")
71 return json.loads(result.stdout)
109 @brief Map each normalized artifact token onto a declared logical identity.
111 An artifact the contract does not name is reported rather than ignored: an
112 unmapped path is a documented-layout gap, which is exactly what a topology
113 contract exists to surface.
114 @param[in] artifacts Normalized artifact tokens.
115 @param[in] contract Parsed topology contract.
116 @return Mapping of identity id to matched tokens, plus an `unmapped` list.
119 "<run.root>":
r"<workspace>/runs/<run_id>",
120 "<run.config>":
r"<workspace>/runs/<run_id>/config",
121 "<run.post_recipes>":
r"<workspace>/runs/<run_id>/config/post\-recipes",
122 "<run.inputs>":
r"<workspace>/runs/<run_id>/inputs",
123 "<run.runtime_logs>":
r"<workspace>/runs/<run_id>/logs",
124 "<run.scheduler>":
r"<workspace>/runs/<run_id>/scheduler",
125 "<run.solver_output>":
r"<workspace>/runs/<run_id>/output",
126 "<run.analysis>":
r"<workspace>/runs/<run_id>/output/analysis",
127 "<run.visualization>":
r"<workspace>/runs/<run_id>/output/visualization",
130 for record
in contract[
"artifacts"]:
131 rule = record[
"path_rule"]
132 pattern = re.escape(rule)
133 pattern = pattern.replace(re.escape(
"<workspace>"),
r"<workspace>")
134 for token, resolved
in logical_roots.items():
135 pattern = pattern.replace(re.escape(token), resolved)
136 pattern = pattern.replace(re.escape(
"<role>"),
r"[^/]+")
137 pattern = pattern.replace(re.escape(
"<ext>"),
r"[^/]+")
138 pattern = pattern.replace(re.escape(
"<name>"),
r"[^/]+")
139 pattern = pattern.replace(re.escape(
"<recipe>"),
r"[^/]+")
140 pattern = pattern.replace(re.escape(
"<run_id>"),
r"<run_id>")
141 pattern = pattern.replace(re.escape(
"<n>"),
r"[^/]+")
142 rules.append((record[
"id"], re.compile(
"^" + pattern +
"$")))
147 def specificity(item) -> tuple:
149 @brief Rank a rule so literal path rules are tried before wildcard ones.
150 @param[in] item Tuple of identity id and compiled pattern.
151 @return Sort key placing more specific rules first.
153 pattern = item[1].pattern
154 return (pattern.count(
"[^/]+"), -len(pattern))
156 ordered = sorted(rules, key=specificity)
157 mapped: dict = {rid: []
for rid, _
in rules}
159 for token
in artifacts:
160 for rid, pattern
in ordered:
161 if pattern.match(token):
162 mapped[rid].append(token)
165 unmapped.append(token)
166 return {
"mapped": {k: sorted(v)
for k, v
in mapped.items()
if v},
"unmapped": sorted(unmapped)}
171 @brief Build the normalized topology snapshot across scenarios.
172 @return Snapshot mapping.
174 contract = json.loads(CONTRACT_PATH.read_text(encoding=
"utf-8"))
176 workspace = Path(tempfile.mkdtemp(prefix=
"picurv-topology-"))
181 (
"fresh_local_solve_and_post",
"monitor.yml",
182 [
"--solve",
"--post-process"],
"post.yml"),
183 (
"fresh_local_solve_only",
"monitor.yml", [
"--solve"],
186 (
"post_output_request_is_canonicalized",
"monitor.yml",
187 [
"--solve",
"--post-process"], flat_post),
189 for name, monitor, extra, post
in cases:
190 plan =
run_plan(workspace, case_dir, monitor, extra, post)
191 artifacts =
normalize(plan.get(
"artifacts", []), case_dir, plan[
"run_id_preview"])
194 "launch_mode": plan.get(
"launch_mode"),
195 "artifacts": artifacts,
199 shutil.rmtree(workspace, ignore_errors=
True)
201 "default_layout": contract[
"default_layout"],
202 "isolation_enforced":
False,
203 "logical_artifacts": [a[
"id"]
for a
in contract[
"artifacts"]],
204 "scenarios": scenarios,
210 @brief Write or verify the artifact topology snapshot.
211 @return Process status code.
213 parser = argparse.ArgumentParser(description=
"Extract the run artifact topology snapshot.")
214 parser.add_argument(
"--check", action=
"store_true", help=
"Fail if the snapshot is stale.")
215 args = parser.parse_args()
219 except (RuntimeError, subprocess.TimeoutExpired, json.JSONDecodeError)
as error:
220 print(f
"Artifact topology extraction failed: {error}", file=sys.stderr)
223 content = json.dumps(snapshot, indent=2, sort_keys=
True) +
"\n"
225 if not SNAPSHOT_PATH.is_file()
or SNAPSHOT_PATH.read_text(encoding=
"utf-8") != content:
227 "Run artifact topology has changed. The planned artifact set no longer matches "
228 "the recorded snapshot.\n"
229 " Review the pages that document run layout, then refresh with:\n"
230 " make docs-topology",
234 print(f
"Artifact topology snapshot is current ({len(snapshot['scenarios'])} scenario(s)).")
237 SNAPSHOT_PATH.parent.mkdir(parents=
True, exist_ok=
True)
238 SNAPSHOT_PATH.write_text(content, encoding=
"utf-8")
239 total = sum(len(s[
"artifacts"])
for s
in snapshot[
"scenarios"])
240 print(f
"Wrote artifact topology snapshot: {len(snapshot['scenarios'])} scenario(s), {total} artifacts.")