dokimi 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. dokimi/__init__.py +41 -0
  2. dokimi/__main__.py +7 -0
  3. dokimi/_version.py +32 -0
  4. dokimi/agent_profiles/__init__.py +13 -0
  5. dokimi/agent_profiles/loader.py +101 -0
  6. dokimi/artifacts.py +201 -0
  7. dokimi/caching.py +367 -0
  8. dokimi/cli.py +123 -0
  9. dokimi/commands/__init__.py +4 -0
  10. dokimi/commands/answers.py +152 -0
  11. dokimi/commands/arms.py +719 -0
  12. dokimi/commands/baseline.py +276 -0
  13. dokimi/commands/benchmark.py +425 -0
  14. dokimi/commands/catalog.py +190 -0
  15. dokimi/commands/context.py +98 -0
  16. dokimi/commands/dashboard.py +65 -0
  17. dokimi/commands/dataset.py +39 -0
  18. dokimi/commands/evaluate.py +553 -0
  19. dokimi/commands/explore.py +177 -0
  20. dokimi/commands/ingest.py +51 -0
  21. dokimi/commands/judge_reliability.py +452 -0
  22. dokimi/commands/library.py +116 -0
  23. dokimi/commands/novelty.py +977 -0
  24. dokimi/commands/orchestrate.py +130 -0
  25. dokimi/commands/personas.py +128 -0
  26. dokimi/commands/questions.py +398 -0
  27. dokimi/commands/repo.py +332 -0
  28. dokimi/commands/repo_run.py +340 -0
  29. dokimi/commands/report.py +223 -0
  30. dokimi/commands/run.py +99 -0
  31. dokimi/commands/subjects.py +437 -0
  32. dokimi/commands/tasks.py +205 -0
  33. dokimi/commands/tools.py +174 -0
  34. dokimi/config/products.yaml +73 -0
  35. dokimi/config.py +89 -0
  36. dokimi/config_check.py +75 -0
  37. dokimi/console.py +20 -0
  38. dokimi/context/__init__.py +10 -0
  39. dokimi/context/trace.py +237 -0
  40. dokimi/coverage.py +333 -0
  41. dokimi/dashboard/__init__.py +4 -0
  42. dokimi/dashboard/aggregator.py +223 -0
  43. dokimi/dashboard/markdown_renderer.py +132 -0
  44. dokimi/datasets_export.py +196 -0
  45. dokimi/defaults.py +95 -0
  46. dokimi/eval/__init__.py +10 -0
  47. dokimi/eval/agent_runner.py +339 -0
  48. dokimi/eval/answerer.py +482 -0
  49. dokimi/eval/arm_runner.py +633 -0
  50. dokimi/eval/arm_summary.py +262 -0
  51. dokimi/eval/binary_stats.py +235 -0
  52. dokimi/eval/cells.py +238 -0
  53. dokimi/eval/checklist.py +340 -0
  54. dokimi/eval/cross_judge.py +370 -0
  55. dokimi/eval/degrade.py +675 -0
  56. dokimi/eval/diagnoser.py +204 -0
  57. dokimi/eval/grounding.py +89 -0
  58. dokimi/eval/interactions.py +182 -0
  59. dokimi/eval/judge.py +713 -0
  60. dokimi/eval/judge_context.py +312 -0
  61. dokimi/eval/judge_sequential.py +176 -0
  62. dokimi/eval/judge_validity.py +482 -0
  63. dokimi/eval/judge_wording.py +243 -0
  64. dokimi/eval/matrix_rollup.py +132 -0
  65. dokimi/eval/pairwise.py +325 -0
  66. dokimi/eval/panel.py +588 -0
  67. dokimi/eval/plugin_delta.py +245 -0
  68. dokimi/eval/question_text.py +84 -0
  69. dokimi/eval/ragas_eval.py +370 -0
  70. dokimi/eval/reranker.py +166 -0
  71. dokimi/eval/stats.py +584 -0
  72. dokimi/eval/typed_scorer.py +298 -0
  73. dokimi/eval/validity_scoring.py +176 -0
  74. dokimi/explore/__init__.py +31 -0
  75. dokimi/explore/explorer.py +215 -0
  76. dokimi/explore/runner.py +161 -0
  77. dokimi/explore/tasks.py +206 -0
  78. dokimi/external/__init__.py +18 -0
  79. dokimi/external/catalog.py +300 -0
  80. dokimi/external/trigger_set.py +259 -0
  81. dokimi/gate/__init__.py +4 -0
  82. dokimi/gate/critical_bands.py +81 -0
  83. dokimi/gate/novelty_gate.py +599 -0
  84. dokimi/gate/regression.py +106 -0
  85. dokimi/gate/score_gate.py +52 -0
  86. dokimi/gate/trust_gate.py +295 -0
  87. dokimi/harnesses/__init__.py +25 -0
  88. dokimi/harnesses/agent_sandbox.py +539 -0
  89. dokimi/harnesses/benchflow.py +869 -0
  90. dokimi/harnesses/claude_json.py +174 -0
  91. dokimi/harnesses/command.py +279 -0
  92. dokimi/harnesses/container_agent.py +383 -0
  93. dokimi/harnesses/docker_solver.py +850 -0
  94. dokimi/harnesses/egress_proxy.py +410 -0
  95. dokimi/harnesses/exploration.py +451 -0
  96. dokimi/harnesses/models.py +127 -0
  97. dokimi/harnesses/operations.py +111 -0
  98. dokimi/harnesses/registry.py +164 -0
  99. dokimi/harnesses/runner.py +441 -0
  100. dokimi/harnesses/skill_treatment.py +298 -0
  101. dokimi/harnesses/solver_stream.py +131 -0
  102. dokimi/harnesses/tasks.py +630 -0
  103. dokimi/ingest/__init__.py +4 -0
  104. dokimi/ingest/chunker.py +26 -0
  105. dokimi/ingest/docling_loader.py +85 -0
  106. dokimi/ingest/loader.py +22 -0
  107. dokimi/intents.py +117 -0
  108. dokimi/intents.yaml +127 -0
  109. dokimi/llm.py +1112 -0
  110. dokimi/mcp/__init__.py +92 -0
  111. dokimi/mcp/context7.py +244 -0
  112. dokimi/mcp/factory.py +211 -0
  113. dokimi/mcp/local_markdown.py +146 -0
  114. dokimi/mcp/mcp_protocol.py +215 -0
  115. dokimi/mcp/okf_client.py +233 -0
  116. dokimi/mcp/url_client.py +173 -0
  117. dokimi/mcp/utils.py +67 -0
  118. dokimi/metrics/__init__.py +11 -0
  119. dokimi/metrics/coverage.py +27 -0
  120. dokimi/metrics/example_runner.py +239 -0
  121. dokimi/metrics/exploration.py +396 -0
  122. dokimi/metrics/freshness_lite.py +27 -0
  123. dokimi/metrics/readability.py +46 -0
  124. dokimi/metrics/usage.py +225 -0
  125. dokimi/novelty/__init__.py +53 -0
  126. dokimi/novelty/analyze.py +482 -0
  127. dokimi/novelty/atoms.py +536 -0
  128. dokimi/novelty/attribute.py +347 -0
  129. dokimi/novelty/batching.py +98 -0
  130. dokimi/novelty/bundle.py +994 -0
  131. dokimi/novelty/classify.py +347 -0
  132. dokimi/novelty/client.py +230 -0
  133. dokimi/novelty/items.py +220 -0
  134. dokimi/novelty/measure.py +539 -0
  135. dokimi/novelty/needs.py +242 -0
  136. dokimi/novelty/probes.py +474 -0
  137. dokimi/novelty/report.py +555 -0
  138. dokimi/novelty/retention.py +263 -0
  139. dokimi/novelty/routing.py +317 -0
  140. dokimi/novelty/shorten.py +334 -0
  141. dokimi/novelty/spans.py +315 -0
  142. dokimi/novelty/tiers.py +240 -0
  143. dokimi/orchestrator/__init__.py +8 -0
  144. dokimi/orchestrator/pipeline.py +714 -0
  145. dokimi/personas/__init__.py +9 -0
  146. dokimi/personas/analyzer.py +358 -0
  147. dokimi/personas/generator.py +239 -0
  148. dokimi/plugins.py +334 -0
  149. dokimi/process.py +766 -0
  150. dokimi/products.yaml +336 -0
  151. dokimi/questions/__init__.py +10 -0
  152. dokimi/questions/artifact.py +266 -0
  153. dokimi/questions/chunk_gen.py +262 -0
  154. dokimi/questions/llm_gen.py +347 -0
  155. dokimi/questions/normalizer.py +116 -0
  156. dokimi/questions/panel_reviewer.py +345 -0
  157. dokimi/questions/quality_analyzer.py +177 -0
  158. dokimi/questions/ragas_seed.py +192 -0
  159. dokimi/questions/refiner.py +318 -0
  160. dokimi/questions/validator.py +498 -0
  161. dokimi/registry.py +103 -0
  162. dokimi/report/__init__.py +22 -0
  163. dokimi/report/arms_report.py +302 -0
  164. dokimi/report/eval_report.py +478 -0
  165. dokimi/report/exploration_report.py +115 -0
  166. dokimi/report/generator.py +539 -0
  167. dokimi/report/json_report.py +15 -0
  168. dokimi/report/markdown_report.py +87 -0
  169. dokimi/report/matrix_rollup_report.py +57 -0
  170. dokimi/report/model_compare.py +1317 -0
  171. dokimi/report/plugin_delta_report.py +104 -0
  172. dokimi/report/subject_report.py +162 -0
  173. dokimi/report/task_runs_report.py +337 -0
  174. dokimi/repos/__init__.py +33 -0
  175. dokimi/repos/bases.py +57 -0
  176. dokimi/repos/compare.py +447 -0
  177. dokimi/repos/container.py +217 -0
  178. dokimi/repos/events.py +204 -0
  179. dokimi/repos/grading.py +253 -0
  180. dokimi/repos/harness_profile.py +192 -0
  181. dokimi/repos/l0/__init__.py +135 -0
  182. dokimi/repos/l0/adapters.py +475 -0
  183. dokimi/repos/l0/core.py +601 -0
  184. dokimi/repos/l0/markdown.py +68 -0
  185. dokimi/repos/manifest.py +541 -0
  186. dokimi/repos/profiles/claude-code.yaml +85 -0
  187. dokimi/repos/reach.py +454 -0
  188. dokimi/repos/report.py +137 -0
  189. dokimi/repos/runner.py +369 -0
  190. dokimi/repos/selfcheck.py +149 -0
  191. dokimi/repos/source.py +449 -0
  192. dokimi/repos/static.py +36 -0
  193. dokimi/repos/support/no_background.py +80 -0
  194. dokimi/repos/workspace.py +329 -0
  195. dokimi/runner/__init__.py +4 -0
  196. dokimi/runner/compare.py +70 -0
  197. dokimi/runner/manifest.py +106 -0
  198. dokimi/runner/run.py +165 -0
  199. dokimi/runner/spec.py +136 -0
  200. dokimi/runner/spec.schema.json +214 -0
  201. dokimi/schema_check.py +44 -0
  202. dokimi/schema_compat.py +355 -0
  203. dokimi/schemas/answers.v1.json +94 -0
  204. dokimi/schemas/arms.v1.json +400 -0
  205. dokimi/schemas/eval.v1.json +98 -0
  206. dokimi/schemas/explore_runs.v1.json +212 -0
  207. dokimi/schemas/guidance_reach.v1.json +106 -0
  208. dokimi/schemas/harness_profile.v1.json +63 -0
  209. dokimi/schemas/matrix_rollup.v1.json +105 -0
  210. dokimi/schemas/novelty.v1.json +485 -0
  211. dokimi/schemas/plugin_delta.v1.json +68 -0
  212. dokimi/schemas/questions.v1.json +93 -0
  213. dokimi/schemas/questions.v2.json +230 -0
  214. dokimi/schemas/repo_grade.v1.json +20 -0
  215. dokimi/schemas/repo_manifest.v1.json +80 -0
  216. dokimi/schemas/repo_readiness.v1.json +88 -0
  217. dokimi/schemas/repo_static.v1.json +23 -0
  218. dokimi/schemas/subject_scorecard.v1.json +109 -0
  219. dokimi/schemas/subject_scorecard.v2.json +266 -0
  220. dokimi/schemas/task_runs.v1.json +579 -0
  221. dokimi/schemas/tool_preference.v1.json +118 -0
  222. dokimi/schemas/tool_preference_groups.v1.json +76 -0
  223. dokimi/skills/__init__.py +8 -0
  224. dokimi/skills/loader.py +154 -0
  225. dokimi/subjects/__init__.py +10 -0
  226. dokimi/subjects/loader.py +215 -0
  227. dokimi/subjects/models.py +63 -0
  228. dokimi/subjects/novelty_track.py +123 -0
  229. dokimi/subjects/scorecard.py +173 -0
  230. dokimi/tool_preference.py +380 -0
  231. dokimi/treatments/__init__.py +56 -0
  232. dokimi/treatments/arms.py +464 -0
  233. dokimi/treatments/base.py +124 -0
  234. dokimi/treatments/factory.py +102 -0
  235. dokimi/treatments/specs.py +234 -0
  236. dokimi/treatments/tools.py +177 -0
  237. dokimi/utils.py +178 -0
  238. dokimi-0.1.0.dist-info/METADATA +402 -0
  239. dokimi-0.1.0.dist-info/RECORD +244 -0
  240. dokimi-0.1.0.dist-info/WHEEL +5 -0
  241. dokimi-0.1.0.dist-info/entry_points.txt +2 -0
  242. dokimi-0.1.0.dist-info/licenses/LICENSE +202 -0
  243. dokimi-0.1.0.dist-info/licenses/NOTICE +6 -0
  244. dokimi-0.1.0.dist-info/top_level.txt +1 -0
dokimi/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ # Copyright (C) 2026 Intel Corporation
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Dokimi: measure whether context improves a coding agent's answers and task outcomes.
5
+
6
+ The context under test is documentation, a skill, an MCP doc source or an agent
7
+ persona prompt.
8
+
9
+ The command line, `dokimi` (`python cli.py` in a source checkout), has its own
10
+ CLI reference; this reference covers the modules behind it. They group by
11
+ evaluation track, as `docs/architecture.md` lays out:
12
+
13
+ - Static documentation track: [`ingest`][dokimi.ingest] loads Markdown,
14
+ [`metrics`][dokimi.metrics] scores it, [`runner`][dokimi.runner] runs a
15
+ benchmark spec and compares snapshots, [`gate`][dokimi.gate] applies the
16
+ gates, [`report`][dokimi.report] writes the result.
17
+ - LLM evaluation track: [`personas`][dokimi.personas],
18
+ [`questions`][dokimi.questions], [`mcp`][dokimi.mcp] (doc-source clients),
19
+ [`eval`][dokimi.eval] (answering and judging),
20
+ [`dashboard`][dokimi.dashboard] and [`orchestrator`][dokimi.orchestrator].
21
+ - Treatment arms: [`treatments`][dokimi.treatments], with the content they
22
+ inject from [`skills`][dokimi.skills] and
23
+ [`agent_profiles`][dokimi.agent_profiles].
24
+ - Executable tasks: [`harnesses`][dokimi.harnesses] runs coding agents on task
25
+ directories; [`context`][dokimi.context] reads what their context window
26
+ held.
27
+ - Content novelty: [`novelty`][dokimi.novelty]. Repositories as subjects:
28
+ [`repos`][dokimi.repos]. Scorecards over time: [`subjects`][dokimi.subjects].
29
+ Exploration quality: [`explore`][dokimi.explore]. External catalogs:
30
+ [`external`][dokimi.external].
31
+
32
+ Shared plumbing sits in the top-level modules: [`artifacts`][dokimi.artifacts]
33
+ (versioned artifact schemas), [`llm`][dokimi.llm] (model calls),
34
+ [`config`][dokimi.config] and [`registry`][dokimi.registry].
35
+
36
+ The installed version is `dokimi.__version__`.
37
+ """
38
+
39
+ from dokimi._version import __version__
40
+
41
+ __all__ = ["__version__"]
dokimi/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # Copyright (C) 2026 Intel Corporation
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from dokimi.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
dokimi/_version.py ADDED
@@ -0,0 +1,32 @@
1
+ # Copyright (C) 2026 Intel Corporation
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """The dokimi version, single-sourced from ``project.version`` in pyproject.toml.
5
+
6
+ A source checkout reads pyproject.toml directly, so the version is right even
7
+ when the tree is used without installing it (``python cli.py``) or after a bump
8
+ that an editable install's metadata has not caught up with. An installed wheel
9
+ has no pyproject.toml next to the package and reads the distribution metadata.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import tomllib
15
+ from importlib import metadata
16
+ from pathlib import Path
17
+
18
+ _PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml"
19
+
20
+
21
+ def _read_version() -> str:
22
+ if _PYPROJECT.is_file():
23
+ project = tomllib.loads(_PYPROJECT.read_text(encoding="utf-8")).get("project", {})
24
+ if project.get("name") == "dokimi" and project.get("version"):
25
+ return str(project["version"])
26
+ try:
27
+ return metadata.version("dokimi")
28
+ except metadata.PackageNotFoundError:
29
+ return "0+unknown"
30
+
31
+
32
+ __version__ = _read_version()
@@ -0,0 +1,13 @@
1
+ # Copyright (C) 2026 Intel Corporation
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Agent persona prompts: load system prompts that shape the answering agent.
5
+
6
+ These are deliberately *not* called "personas" — in this codebase ``persona``
7
+ means a synthetic user who asks questions (``dokimi/personas``). An
8
+ agent profile is a property of the *answering* agent (its system prompt).
9
+ """
10
+
11
+ from .loader import AgentProfile, load_agent_profile, discover_agent_profiles
12
+
13
+ __all__ = ["AgentProfile", "load_agent_profile", "discover_agent_profiles"]
@@ -0,0 +1,101 @@
1
+ # Copyright (C) 2026 Intel Corporation
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Load agent persona prompts (answering-agent system prompts).
5
+
6
+ An agent profile is a Markdown file with optional YAML frontmatter::
7
+
8
+ ---
9
+ id: concise_expert
10
+ name: Concise Expert
11
+ description: Terse, code-first senior engineer.
12
+ ---
13
+ You are a senior systems engineer. Answer tersely, lead with code, …
14
+
15
+ The body (frontmatter stripped) is the system prompt. When no frontmatter is
16
+ present the whole file is the system prompt and the id is derived from the
17
+ filename.
18
+ """
19
+
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import List
23
+
24
+ from dokimi.utils import parse_frontmatter
25
+
26
+
27
+ @dataclass
28
+ class AgentProfile:
29
+ """A loaded agent persona prompt.
30
+
31
+ Attributes:
32
+ id: Stable identifier (frontmatter ``id`` or the filename stem).
33
+ name: Human-readable name.
34
+ description: One-line summary of the profile.
35
+ system_prompt: The system prompt sent to the answering agent.
36
+ path: Source file path.
37
+ """
38
+
39
+ id: str
40
+ name: str
41
+ description: str
42
+ system_prompt: str
43
+ path: Path
44
+
45
+ def as_prompt(self, max_chars: int = 12_000) -> str:
46
+ """Render the prompt as it will be sent, capped at *max_chars*.
47
+
48
+ The cap mirrors :meth:`dokimi.skills.Skill.as_context` — see that
49
+ docstring for where the number came from (an inherited convention, not a tuned
50
+ value). The parity, not the figure, is what matters here: comparing the
51
+ system-prompt channel against the context channel is only meaningful if one
52
+ side cannot carry an unbounded payload the other would have had truncated.
53
+ Truncation is recorded by the caller rather than hidden — see
54
+ ``AgentProfileTreatment``.
55
+ """
56
+ prompt = self.system_prompt.strip()
57
+ if len(prompt) > max_chars:
58
+ return prompt[:max_chars].rstrip() + "\n…"
59
+ return prompt
60
+
61
+
62
+ def load_agent_profile(path) -> AgentProfile:
63
+ """Load a single agent profile from a Markdown file.
64
+
65
+ Args:
66
+ path: Path to the profile ``.md`` file.
67
+
68
+ Returns:
69
+ An :class:`AgentProfile`.
70
+
71
+ Raises:
72
+ FileNotFoundError: If the file does not exist.
73
+ ValueError: If the profile has no system-prompt body.
74
+ """
75
+ p = Path(path)
76
+ if not p.is_file():
77
+ raise FileNotFoundError(f"Agent profile not found: {p}")
78
+
79
+ meta, body = parse_frontmatter(p.read_text(encoding="utf-8"))
80
+ system_prompt = body.strip()
81
+ if not system_prompt:
82
+ raise ValueError(
83
+ f"Agent profile '{p}' has an empty body; the body is the system prompt."
84
+ )
85
+
86
+ pid = str(meta.get("id") or p.stem).strip()
87
+ if not pid:
88
+ raise ValueError(f"Agent profile '{p}' has an empty 'id' after normalization.")
89
+ return AgentProfile(
90
+ id=pid,
91
+ name=str(meta.get("name") or pid).strip(),
92
+ description=str(meta.get("description") or "").strip(),
93
+ system_prompt=system_prompt,
94
+ path=p,
95
+ )
96
+
97
+
98
+ def discover_agent_profiles(root) -> List[AgentProfile]:
99
+ """Load every ``.md`` agent profile under *root*."""
100
+ root = Path(root)
101
+ return [load_agent_profile(p) for p in sorted(root.glob("*.md"))]
dokimi/artifacts.py ADDED
@@ -0,0 +1,201 @@
1
+ # Copyright (C) 2026 Intel Corporation
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Versioned, schema-validated I/O for produced artifacts.
5
+
6
+ The benchmark produces long-lived JSON artifact kinds: ``questions``,
7
+ ``answers``, ``eval`` (LLM-judge scores), ``arms`` (treatment-arm comparisons),
8
+ ``task_runs`` (executable task results), ``explore_runs`` (ExploreBench
9
+ localization results), ``novelty`` (what an artifact's content adds to one
10
+ model), ``guidance_reach`` (L1 reach probes) and ``repo_readiness`` (L0 claim
11
+ checks) and ``tool_preference`` (which of two equal tools a model picks, and
12
+ what moves it). This module stamps each with a ``schema_version`` and
13
+ validates it against the JSON Schema in ``dokimi/schemas/`` on save
14
+ and load, so fixtures and baselines stay forward-compatible and self-describing.
15
+
16
+ Every artifact of a registered kind is written through :func:`save_artifact`, partial
17
+ (incremental) saves included: a run that would write an invalid artifact fails on its first
18
+ save, not after the last paid model call. :func:`schema_errors` is the one place a JSON Schema
19
+ is applied, for artifacts and for validated *inputs* (manifests, profiles, specs) alike.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import logging
26
+ import os
27
+ import threading
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ SCHEMA_DIR = Path(__file__).resolve().parent / "schemas"
34
+
35
+ # kind -> current schema_version string (also the schema filename stem).
36
+ SCHEMA_VERSIONS: dict[str, str] = {
37
+ "questions": "questions.v2",
38
+ "answers": "answers.v1",
39
+ "eval": "eval.v1",
40
+ "arms": "arms.v1",
41
+ "matrix_rollup": "matrix_rollup.v1",
42
+ "plugin_delta": "plugin_delta.v1",
43
+ "subject_scorecard": "subject_scorecard.v2",
44
+ "novelty": "novelty.v1",
45
+ "task_runs": "task_runs.v1",
46
+ "explore_runs": "explore_runs.v1",
47
+ "guidance_reach": "guidance_reach.v1",
48
+ "repo_readiness": "repo_readiness.v1",
49
+ "tool_preference": "tool_preference.v1",
50
+ }
51
+
52
+ # kind -> superseded versions still accepted on *load*, newest first. An artifact written under an
53
+ # older version is still a valid artifact and reading one must not fail; `stamp` always writes the
54
+ # current version, so nothing is written back at an old version. A reader that needs the current
55
+ # shape upgrades on read (questions.v1 `text` -> v2 `question`: dokimi.questions.artifact).
56
+ SUPERSEDED_VERSIONS: dict[str, tuple[str, ...]] = {
57
+ "questions": ("questions.v1",),
58
+ "subject_scorecard": ("subject_scorecard.v1",),
59
+ }
60
+
61
+
62
+ class ArtifactValidationError(ValueError):
63
+ """Raised when an artifact does not conform to its schema."""
64
+
65
+
66
+ def _schema_path(kind: str, version: str | None = None) -> Path:
67
+ if kind not in SCHEMA_VERSIONS:
68
+ raise KeyError(f"Unknown artifact kind: {kind!r}. Known: {sorted(SCHEMA_VERSIONS)}")
69
+ return SCHEMA_DIR / f"{version or SCHEMA_VERSIONS[kind]}.json"
70
+
71
+
72
+ def schema_version_for(kind: str, data: dict) -> str:
73
+ """Which schema version ``data`` should be validated against.
74
+
75
+ The current version, unless the payload declares a superseded one for this kind — an artifact
76
+ written before a bump is still valid, so reading it must not fail. An unknown version string is
77
+ *not* honoured: it falls through to the current schema, whose ``const`` then rejects it, which
78
+ is how a typo or a future version stays an error instead of silently skipping validation.
79
+
80
+ An *unstamped* payload (no ``schema_version`` key: hand-written and older sets, e.g. curated
81
+ question files) is matched against the current version, then each superseded one, newest
82
+ first, and resolves to the first it satisfies; when it satisfies none, to the one it violates
83
+ least (the newest on a tie), so a set with one real fault reports that fault, not every
84
+ record as a wrong shape.
85
+ """
86
+ if "schema_version" not in data and SUPERSEDED_VERSIONS.get(kind):
87
+ counts = {v: len(schema_errors(v, data))
88
+ for v in (SCHEMA_VERSIONS[kind], *SUPERSEDED_VERSIONS[kind])}
89
+ return min(counts, key=counts.__getitem__)
90
+ declared = data.get("schema_version")
91
+ if declared in SUPERSEDED_VERSIONS.get(kind, ()):
92
+ return str(declared)
93
+ return SCHEMA_VERSIONS[kind]
94
+
95
+
96
+ def schema_errors(schema: str | Path, instance: Any) -> list[str]:
97
+ """Every way ``instance`` violates a JSON Schema (draft 2020-12); empty when it conforms.
98
+
99
+ ``schema`` is a schema name in :data:`SCHEMA_DIR` (``"repo_static.v1"``) or the path of a
100
+ schema file elsewhere. Each problem reads ``<path>: <message>``, ``<path>`` being the
101
+ ``/``-joined location in ``instance`` (``<root>`` for the top level), sorted by location.
102
+ An unreadable or malformed schema file raises ``OSError`` / ``json.JSONDecodeError``: that
103
+ is a packaging fault, not a problem with ``instance``.
104
+ """
105
+ from jsonschema import Draft202012Validator
106
+
107
+ path = SCHEMA_DIR / f"{schema}.json" if isinstance(schema, str) else Path(schema)
108
+ document = json.loads(path.read_text(encoding="utf-8"))
109
+ return [
110
+ f"{'/'.join(map(str, e.path)) or '<root>'}: {e.message}"
111
+ for e in sorted(Draft202012Validator(document).iter_errors(instance),
112
+ key=lambda e: list(e.path))
113
+ ]
114
+
115
+
116
+ def validate_artifact(kind: str, data: dict) -> None:
117
+ """Validate ``data`` against the schema for ``kind`` (raises on failure)."""
118
+ errors = schema_errors(_schema_path(kind, schema_version_for(kind, data)), data)
119
+ if errors:
120
+ details = "\n".join(f" - {e}" for e in errors)
121
+ raise ArtifactValidationError(f"{kind} artifact validation failed:\n{details}")
122
+
123
+
124
+ def stamp(kind: str, data: dict) -> dict:
125
+ """Return ``data`` with its ``schema_version`` set for ``kind``."""
126
+ if kind not in SCHEMA_VERSIONS:
127
+ raise KeyError(f"Unknown artifact kind: {kind!r}")
128
+ data["schema_version"] = SCHEMA_VERSIONS[kind]
129
+ return data
130
+
131
+
132
+ def save_artifact(kind: str, data: dict, path: Path | str) -> dict:
133
+ """Write ``data`` to ``path`` as a ``kind`` artifact; return exactly what was written.
134
+
135
+ - ``schema_version`` is set to the current version for ``kind`` and leads the document
136
+ (a value already in ``data`` is replaced); ``data`` itself is not mutated.
137
+ - The payload is validated first. :class:`ArtifactValidationError` means nothing was
138
+ written at ``path`` (a file already there is untouched); the rejected payload is kept
139
+ at ``<name>.rejected`` so a paid run is not lost to a schema problem. A later valid
140
+ save to ``path`` removes that ``<name>.rejected`` (only that sibling), so a leftover
141
+ always belongs to the latest attempt; if it cannot be removed the save still stands and
142
+ a warning is logged. ``KeyError`` means ``kind`` is not registered in
143
+ :data:`SCHEMA_VERSIONS`.
144
+ - The write is atomic (a ``<name>.<pid>.<thread>.tmp`` sibling, then ``os.replace``), so a
145
+ reader or a crash never sees half a file, and concurrent saves to one path (threads or
146
+ processes) each publish one whole payload; the last one wins. A failed write removes its
147
+ tmp file. Missing parent directories of ``path`` are created.
148
+ - A symlink at ``path`` is kept: the payload replaces the file it resolves to (the tmp file
149
+ sits beside that target, so the rename stays on one filesystem). A dangling link creates
150
+ its target when the target's directory exists; otherwise ``FileNotFoundError`` is raised
151
+ and nothing is written (the target's directories are *not* created). A link loop raises
152
+ ``OSError``. ``<name>.rejected`` stays beside ``path``, not the target.
153
+ - JSON with ``indent=2``, ASCII-escaped, UTF-8.
154
+ """
155
+ if kind not in SCHEMA_VERSIONS:
156
+ raise KeyError(f"Unknown artifact kind: {kind!r}")
157
+ payload = {"schema_version": SCHEMA_VERSIONS[kind],
158
+ **{k: v for k, v in data.items() if k != "schema_version"}}
159
+ path = Path(path)
160
+ path.parent.mkdir(parents=True, exist_ok=True)
161
+ text = json.dumps(payload, indent=2)
162
+ try:
163
+ validate_artifact(kind, payload)
164
+ except ArtifactValidationError as exc:
165
+ rejected = path.with_name(path.name + ".rejected")
166
+ rejected.write_text(text, encoding="utf-8")
167
+ raise ArtifactValidationError(f"{exc}\n(not written to {path}; payload kept at {rejected})") from exc
168
+ target = _write_target(path)
169
+ tmp = target.with_name(f"{target.name}.{os.getpid()}.{threading.get_ident()}.tmp")
170
+ try:
171
+ tmp.write_text(text, encoding="utf-8")
172
+ os.replace(tmp, target)
173
+ except BaseException:
174
+ tmp.unlink(missing_ok=True)
175
+ raise
176
+ stale = path.with_name(path.name + ".rejected")
177
+ try:
178
+ stale.unlink(missing_ok=True)
179
+ except OSError as exc:
180
+ logger.warning("saved %s, but could not remove the stale rejected payload %s: %s", path, stale, exc)
181
+ return payload
182
+
183
+
184
+ def _write_target(path: Path) -> Path:
185
+ """The file a save to ``path`` replaces: ``path`` itself, or what a symlink there resolves to."""
186
+ if not path.is_symlink():
187
+ return path
188
+ target = Path(os.path.realpath(path))
189
+ if target.is_symlink(): # realpath gives up on a loop and hands back a link
190
+ raise OSError(f"symlink {path} does not resolve to a file (link loop at {target})")
191
+ if not target.parent.is_dir():
192
+ raise FileNotFoundError(f"symlink {path} points into a missing directory: {target}")
193
+ return target
194
+
195
+
196
+ def load_artifact(kind: str, path: Path, *, validate: bool = True) -> dict:
197
+ """Read and (optionally) validate an artifact from ``path``."""
198
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
199
+ if validate:
200
+ validate_artifact(kind, data)
201
+ return data