Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / tools / model_integration / evidence_store.py: 84%

146 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-10-25 05:45 +0800

1# Copyright 2026 Huawei Technologies Co., Ltd 

2# 

3# Licensed under the Apache License, Version 2.0 (the "License"); 

4# you may not use this file except in compliance with the License. 

5# You may obtain a copy of the License at 

6# 

7# http://www.apache.org/licenses/LICENSE-2.0 

8# 

9# Unless required by applicable law or agreed to in writing, software 

10# distributed under the License is distributed on an "AS IS" BASIS, 

11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

12# See the License for the specific language governing permissions and 

13# limitations under the License. 

14# ============================================================================ 

15"""Evidence directory writer and deterministic Markdown report generator.""" 

16 

17from __future__ import annotations 

18 

19import csv 

20import json 

21import os 

22import platform 

23import subprocess 

24import sys 

25import tempfile 

26from dataclasses import asdict, is_dataclass 

27from enum import Enum 

28from pathlib import Path 

29from typing import Any, Iterable, Mapping, Sequence 

30 

31import yaml 

32 

33from hyper_parallel.tools.model_integration.schemas import IntegrationState, SCHEMA_VERSION 

34 

35 

36_STATE_ORDER = ( 

37 IntegrationState.DISCOVERED, 

38 IntegrationState.STRUCTURE_VALIDATED, 

39 IntegrationState.MODULE_PARITY_PASSED, 

40 IntegrationState.MATRIX_PASSED, 

41) 

42 

43 

44def find_repository_root() -> Path: 

45 """Find the checkout root without depending on this module's depth.""" 

46 for candidate in Path(__file__).resolve().parents: 

47 if candidate.joinpath("pyproject.toml").is_file() and candidate.joinpath( 

48 "hyper_parallel" 

49 ).is_dir(): 

50 return candidate 

51 return Path.cwd().resolve() 

52 

53 

54def _json_default(value: Any) -> Any: 

55 if is_dataclass(value): 

56 return asdict(value) 

57 if isinstance(value, Path): 

58 return str(value) 

59 if isinstance(value, Enum): 

60 return value.value 

61 return repr(value) 

62 

63 

64class EvidenceStore: 

65 """Write versioned evidence without parsing free-form Trainer logs.""" 

66 

67 def __init__(self, root: str | Path) -> None: 

68 """Create an evidence writer rooted at one independent run directory.""" 

69 self.root = Path(root).expanduser().resolve() 

70 self.root.mkdir(parents=True, exist_ok=True) 

71 

72 def path(self, relative_path: str | Path) -> Path: 

73 """Resolve an evidence-relative path and reject directory traversal.""" 

74 resolved = (self.root / relative_path).resolve() 

75 if resolved != self.root and self.root not in resolved.parents: 

76 raise ValueError(f"evidence path escapes run directory: {relative_path}") 

77 return resolved 

78 

79 @staticmethod 

80 def _atomic_write(path: Path, content: str) -> None: 

81 path.parent.mkdir(parents=True, exist_ok=True) 

82 descriptor, temporary_name = tempfile.mkstemp( 

83 dir=path.parent, 

84 prefix=f".{path.name}.", 

85 suffix=".tmp", 

86 text=True, 

87 ) 

88 try: 

89 with os.fdopen(descriptor, "w", encoding="utf-8") as output_file: 

90 output_file.write(content) 

91 output_file.flush() 

92 os.fsync(output_file.fileno()) 

93 os.replace(temporary_name, path) 

94 except BaseException: 

95 try: 

96 os.unlink(temporary_name) 

97 except FileNotFoundError: 

98 pass 

99 raise 

100 

101 def write_json(self, relative_path: str | Path, payload: Any) -> Path: 

102 """Write sorted, UTF-8 JSON atomically.""" 

103 output_path = self.path(relative_path) 

104 content = json.dumps( 

105 payload, 

106 indent=2, 

107 ensure_ascii=False, 

108 sort_keys=True, 

109 default=_json_default, 

110 ) + "\n" 

111 self._atomic_write(output_path, content) 

112 return output_path 

113 

114 def write_yaml(self, relative_path: str | Path, payload: Any) -> Path: 

115 """Write a resolved YAML object atomically.""" 

116 output_path = self.path(relative_path) 

117 content = yaml.safe_dump(payload, allow_unicode=True, sort_keys=False) 

118 self._atomic_write(output_path, content) 

119 return output_path 

120 

121 def append_jsonl(self, relative_path: str | Path, payload: Any) -> Path: 

122 """Append one structured record to a JSON-lines evidence stream.""" 

123 output_path = self.path(relative_path) 

124 output_path.parent.mkdir(parents=True, exist_ok=True) 

125 with output_path.open("a", encoding="utf-8") as output_file: 

126 output_file.write(json.dumps(payload, sort_keys=True, default=_json_default) + "\n") 

127 output_file.flush() 

128 return output_path 

129 

130 def write_csv( 

131 self, 

132 relative_path: str | Path, 

133 rows: Iterable[Mapping[str, Any]], 

134 fieldnames: Sequence[str], 

135 ) -> Path: 

136 """Write a stable-column CSV evidence table.""" 

137 output_path = self.path(relative_path) 

138 output_path.parent.mkdir(parents=True, exist_ok=True) 

139 with output_path.open("w", encoding="utf-8", newline="") as output_file: 

140 writer = csv.DictWriter(output_file, fieldnames=fieldnames) 

141 writer.writeheader() 

142 for row in rows: 

143 writer.writerow({name: row.get(name, "") for name in fieldnames}) 

144 return output_path 

145 

146 def capture_environment(self) -> dict[str, Any]: 

147 """Capture imports and revisions that determine reproducibility.""" 

148 git_revision = None 

149 source_root = find_repository_root() 

150 try: 

151 git_revision = subprocess.run( 

152 ["git", "rev-parse", "HEAD"], 

153 cwd=source_root, 

154 check=True, 

155 capture_output=True, 

156 text=True, 

157 ).stdout.strip() 

158 except (OSError, subprocess.CalledProcessError): 

159 pass 

160 environment = { 

161 "python": sys.version, 

162 "executable": sys.executable, 

163 "platform": platform.platform(), 

164 "git_revision": git_revision, 

165 "source_root": str(source_root), 

166 "sys_path": list(sys.path), 

167 } 

168 try: 

169 import hyper_parallel # pylint: disable=import-outside-toplevel 

170 

171 environment["hyper_parallel"] = { 

172 "import_path": str(Path(hyper_parallel.__file__).resolve()), 

173 } 

174 except ImportError: 

175 environment["hyper_parallel"] = None 

176 try: 

177 import torch # pylint: disable=import-outside-toplevel,forbidden-backend-import 

178 

179 environment["torch"] = { 

180 "version": torch.__version__, 

181 "import_path": str(Path(torch.__file__).resolve()), 

182 } 

183 except ImportError: 

184 environment["torch"] = None 

185 try: 

186 import transformers # pylint: disable=import-outside-toplevel 

187 

188 environment["transformers"] = { 

189 "version": transformers.__version__, 

190 "import_path": str(Path(transformers.__file__).resolve()), 

191 } 

192 except ImportError: 

193 environment["transformers"] = None 

194 self.write_json("environment.json", environment) 

195 return environment 

196 

197 def update_state( 

198 self, 

199 state: IntegrationState, 

200 evidence: Iterable[str] = (), 

201 reason: str = "", 

202 ) -> Path: 

203 """Advance the evidence-backed workflow state without skipping gates.""" 

204 state_path = self.path("integration_state.json") 

205 previous = None 

206 if state_path.exists(): 

207 previous = IntegrationState(json.loads(state_path.read_text(encoding="utf-8"))["state"]) 

208 terminal = (IntegrationState.FAILED, IntegrationState.BLOCKED) 

209 if previous in terminal and state not in terminal: 

210 raise ValueError(f"cannot advance terminal integration state {previous.value}") 

211 if state not in terminal and previous is not None: 

212 previous_index = _STATE_ORDER.index(previous) 

213 next_index = _STATE_ORDER.index(state) 

214 if next_index < previous_index: 

215 raise ValueError( 

216 f"cannot regress integration state {previous.value} -> {state.value}" 

217 ) 

218 if next_index > previous_index + 1: 

219 raise ValueError( 

220 f"cannot skip integration state {previous.value} -> {state.value}" 

221 ) 

222 payload = { 

223 "schema_version": SCHEMA_VERSION, 

224 "state": state.value, 

225 "previous_state": previous.value if previous else None, 

226 "evidence": sorted(set(evidence)), 

227 "reason": reason, 

228 } 

229 return self.write_json("integration_state.json", payload) 

230 

231 def current_state(self) -> IntegrationState | None: 

232 """Return the persisted workflow state, if this run has one.""" 

233 state_path = self.path("integration_state.json") 

234 if not state_path.exists(): 

235 return None 

236 return IntegrationState(json.loads(state_path.read_text(encoding="utf-8"))["state"]) 

237 

238 def advance_if_before( 

239 self, 

240 state: IntegrationState, 

241 evidence: Iterable[str] = (), 

242 reason: str = "", 

243 ) -> Path: 

244 """Advance one adjacent gate, or preserve an already-later valid gate. 

245 

246 This is the restart-safe counterpart to :meth:`update_state`. It never 

247 rewrites the persisted state backwards, while ``update_state`` remains 

248 strict enough to catch accidental regression in ordinary callers. 

249 """ 

250 current = self.current_state() 

251 if current in (IntegrationState.FAILED, IntegrationState.BLOCKED): 

252 raise ValueError(f"cannot advance terminal integration state {current.value}") 

253 if current is not None and _STATE_ORDER.index(current) >= _STATE_ORDER.index(state): 

254 return self.path("integration_state.json") 

255 return self.update_state(state, evidence, reason) 

256 

257 def render_summary( 

258 self, 

259 status: str, 

260 title: str, 

261 sections: Sequence[tuple[str, Sequence[str]]], 

262 relative_path: str = "summary.md", 

263 ) -> Path: 

264 """Write a report whose first line is PASS, FAIL, or BLOCKED. 

265 

266 Args: 

267 status: Aggregate report status. 

268 title: Markdown document title. 

269 sections: Ordered headings and bullet entries. 

270 relative_path: Evidence-relative report path. 

271 

272 Returns: 

273 Path to the atomically written report. 

274 """ 

275 if status not in ("PASS", "FAIL", "BLOCKED"): 

276 raise ValueError(f"invalid report status: {status!r}") 

277 lines = [status, "", f"# {title}"] 

278 for heading, entries in sections: 

279 lines.extend(("", f"## {heading}", "")) 

280 lines.extend(f"- {entry}" for entry in entries) 

281 return self._atomic_summary(relative_path, "\n".join(lines) + "\n") 

282 

283 def _atomic_summary(self, relative_path: str, content: str) -> Path: 

284 output_path = self.path(relative_path) 

285 self._atomic_write(output_path, content) 

286 return output_path 

287 

288 

289__all__ = ["EvidenceStore", "find_repository_root"]