Coverage for oc_meta / run / find / hasnext_anomalies.py: 49%

180 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-07-25 10:39 +0000

1# SPDX-FileCopyrightText: 2026 Arcangelo Massari <arcangelo.massari@unibo.it> 

2# 

3# SPDX-License-Identifier: ISC 

4 

5from __future__ import annotations 

6 

7import argparse 

8import os 

9from collections import Counter 

10from datetime import datetime, timezone 

11import multiprocessing 

12from typing import Dict, List, Optional, Tuple 

13 

14import orjson 

15import yaml 

16from rich_argparse import RichHelpFormatter 

17 

18from oc_meta.lib.console import create_progress 

19from oc_meta.lib.file_manager import collect_zip_files 

20from oc_meta.lib.file_manager import find_rdf_file 

21from oc_meta.run.meta.generate_csv import load_json_from_file 

22 

23ROLE_MAP = { 

24 "http://purl.org/spar/pro/author": "author", 

25 "http://purl.org/spar/pro/editor": "editor", 

26 "http://purl.org/spar/pro/publisher": "publisher", 

27} 

28HAS_NEXT = "https://w3id.org/oc/ontology/hasNext" 

29IS_DOC_CONTEXT_FOR = "http://purl.org/spar/pro/isDocumentContextFor" 

30WITH_ROLE = "http://purl.org/spar/pro/withRole" 

31IS_HELD_BY = "http://purl.org/spar/pro/isHeldBy" 

32 

33_worker_config: Optional[Tuple[str, int, int]] = None 

34 

35 

36def _init_worker(rdf_dir: str, dir_split_number: int, items_per_file: int) -> None: 

37 global _worker_config 

38 _worker_config = (rdf_dir, dir_split_number, items_per_file) 

39 

40 

41def _ar_summary(ar_uri: str, info: dict) -> dict: 

42 return { 

43 "ar": ar_uri, 

44 "ra": info["ra"], 

45 "has_next": info["has_next"], 

46 } 

47 

48 

49def load_ar_data( 

50 ar_uri: str, rdf_dir: str, dir_split_number: int, items_per_file: int 

51) -> Optional[dict]: 

52 ar_file = find_rdf_file( 

53 ar_uri, rdf_dir, dir_split_number, items_per_file, zip_output=True 

54 ) 

55 if not os.path.exists(ar_file): 

56 return None 

57 data = load_json_from_file(ar_file) 

58 for graph in data: 

59 for entity in graph["@graph"]: 

60 if entity["@id"] == ar_uri: 

61 role_uri = "" 

62 if WITH_ROLE in entity: 

63 role_uri = entity[WITH_ROLE][0]["@id"] 

64 role_type = ROLE_MAP.get(role_uri, "unknown") 

65 

66 ra_uri = None 

67 if IS_HELD_BY in entity: 

68 ra_uri = entity[IS_HELD_BY][0]["@id"] 

69 

70 has_next = [] 

71 if HAS_NEXT in entity: 

72 has_next = [item["@id"] for item in entity[HAS_NEXT]] 

73 

74 return { 

75 "role_type": role_type, 

76 "ra": ra_uri, 

77 "has_next": has_next, 

78 } 

79 return None 

80 

81 

82def detect_cycles(ar_data: Dict[str, dict], ar_uris_in_group: set) -> List[List[str]]: 

83 adj: Dict[str, List[str]] = {} 

84 for ar_uri, info in ar_data.items(): 

85 targets = [t for t in info["has_next"] if t in ar_uris_in_group and t != ar_uri] 

86 if targets: 

87 adj[ar_uri] = targets 

88 

89 globally_visited: set = set() 

90 cycles: List[List[str]] = [] 

91 

92 for start in ar_uris_in_group: 

93 if start in globally_visited: 

94 continue 

95 

96 path: List[str] = [] 

97 path_set: set = set() 

98 stack: List[Tuple[str, int]] = [(start, -1)] 

99 

100 while stack: 

101 node, ni = stack[-1] 

102 

103 if ni == -1: 

104 if node in path_set: 

105 cycle_start = path.index(node) 

106 cycles.append(list(path[cycle_start:])) 

107 stack.pop() 

108 continue 

109 if node in globally_visited: 

110 stack.pop() 

111 continue 

112 path.append(node) 

113 path_set.add(node) 

114 stack[-1] = (node, 0) 

115 continue 

116 

117 neighbors = adj.get(node, []) 

118 if ni < len(neighbors): 

119 stack[-1] = (node, ni + 1) 

120 stack.append((neighbors[ni], -1)) 

121 else: 

122 path.pop() 

123 path_set.discard(node) 

124 globally_visited.add(node) 

125 stack.pop() 

126 

127 return cycles 

128 

129 

130def find_anomalies(br_uri: str, role_type: str, ar_data: Dict[str, dict]) -> List[dict]: 

131 anomalies: List[dict] = [] 

132 ar_uris_in_group = set(ar_data.keys()) 

133 

134 for ar_uri, info in ar_data.items(): 

135 if ar_uri in info["has_next"]: 

136 anomalies.append( 

137 { 

138 "anomaly_type": "self_loop", 

139 "br": br_uri, 

140 "role_type": role_type, 

141 "ars_involved": [_ar_summary(ar_uri, info)], 

142 "details": f"AR {ar_uri.split('/')[-1]} hasNext points to itself", 

143 } 

144 ) 

145 

146 for ar_uri, info in ar_data.items(): 

147 if len(info["has_next"]) > 1: 

148 anomalies.append( 

149 { 

150 "anomaly_type": "multiple_has_next", 

151 "br": br_uri, 

152 "role_type": role_type, 

153 "ars_involved": [_ar_summary(ar_uri, info)], 

154 "details": ( 

155 f"AR {ar_uri.split('/')[-1]} has" 

156 f" {len(info['has_next'])} hasNext targets" 

157 ), 

158 } 

159 ) 

160 

161 for ar_uri, info in ar_data.items(): 

162 for target in info["has_next"]: 

163 if target not in ar_uris_in_group: 

164 anomalies.append( 

165 { 

166 "anomaly_type": "dangling_has_next", 

167 "br": br_uri, 

168 "role_type": role_type, 

169 "ars_involved": [_ar_summary(ar_uri, info)], 

170 "details": ( 

171 f"AR {ar_uri.split('/')[-1]} hasNext points to" 

172 f" {target.split('/')[-1]} which is not in this" 

173 " BR/role group" 

174 ), 

175 } 

176 ) 

177 

178 referenced_ars = set() 

179 for info in ar_data.values(): 

180 for target in info["has_next"]: 

181 if target in ar_uris_in_group: 

182 referenced_ars.add(target) 

183 

184 start_nodes = [ar for ar in ar_uris_in_group if ar not in referenced_ars] 

185 

186 if len(ar_data) > 1: 

187 if len(start_nodes) == 0: 

188 anomalies.append( 

189 { 

190 "anomaly_type": "no_start_node", 

191 "br": br_uri, 

192 "role_type": role_type, 

193 "ars_involved": [ 

194 _ar_summary(ar_uri, ar_data[ar_uri]) for ar_uri in ar_data 

195 ], 

196 "details": ( 

197 f"All {len(ar_data)} ARs are targets of hasNext" 

198 " (fully circular)" 

199 ), 

200 } 

201 ) 

202 elif len(start_nodes) > 1: 

203 anomalies.append( 

204 { 

205 "anomaly_type": "multiple_start_nodes", 

206 "br": br_uri, 

207 "role_type": role_type, 

208 "ars_involved": [ 

209 _ar_summary(ar_uri, ar_data[ar_uri]) for ar_uri in start_nodes 

210 ], 

211 "details": ( 

212 f"{len(start_nodes)} ARs have no incoming hasNext" 

213 " (disconnected fragments)" 

214 ), 

215 } 

216 ) 

217 

218 cycles = detect_cycles(ar_data, ar_uris_in_group) 

219 for cycle in cycles: 

220 cycle_ids = [uri.split("/")[-1] for uri in cycle] 

221 anomalies.append( 

222 { 

223 "anomaly_type": "cycle", 

224 "br": br_uri, 

225 "role_type": role_type, 

226 "ars_involved": [ 

227 _ar_summary(ar_uri, ar_data[ar_uri]) for ar_uri in cycle 

228 ], 

229 "details": ( 

230 f"{len(cycle)}-node cycle:" 

231 f" {' -> '.join(cycle_ids)} -> {cycle_ids[0]}" 

232 ), 

233 } 

234 ) 

235 

236 return anomalies 

237 

238 

239def _detect_anomalies_in_file(filepath: str) -> Tuple[str, int, List[dict]]: 

240 assert _worker_config is not None 

241 rdf_dir, dir_split_number, items_per_file = _worker_config 

242 anomalies: List[dict] = [] 

243 br_count = 0 

244 data = load_json_from_file(filepath) 

245 for graph in data: 

246 for entity in graph["@graph"]: 

247 if IS_DOC_CONTEXT_FOR not in entity: 

248 continue 

249 br_count += 1 

250 br_uri = entity["@id"] 

251 

252 ar_uris = [ar["@id"] for ar in entity[IS_DOC_CONTEXT_FOR]] 

253 ar_data: Dict[str, dict] = {} 

254 for ar_uri in ar_uris: 

255 info = load_ar_data(ar_uri, rdf_dir, dir_split_number, items_per_file) 

256 if info: 

257 ar_data[ar_uri] = info 

258 

259 role_groups: Dict[str, Dict[str, dict]] = {} 

260 for ar_uri, info in ar_data.items(): 

261 role = info["role_type"] 

262 if role not in role_groups: 

263 role_groups[role] = {} 

264 role_groups[role][ar_uri] = info 

265 

266 for role_type, group in role_groups.items(): 

267 anomalies.extend(find_anomalies(br_uri, role_type, group)) 

268 

269 return (filepath, br_count, anomalies) 

270 

271 

272def main() -> None: 

273 parser = argparse.ArgumentParser( 

274 description="Detect hasNext chain anomalies in RDF data", 

275 formatter_class=RichHelpFormatter, 

276 ) 

277 parser.add_argument( 

278 "-c", "--config", required=True, help="Meta config YAML file path" 

279 ) 

280 parser.add_argument("-o", "--output", required=True, help="Output JSON report path") 

281 parser.add_argument( 

282 "--workers", 

283 type=int, 

284 default=4, 

285 help="Number of parallel workers (default: 4)", 

286 ) 

287 args = parser.parse_args() 

288 

289 with open(args.config, encoding="utf-8") as f: 

290 settings = yaml.safe_load(f) 

291 

292 rdf_dir = os.path.join(settings["output_rdf_dir"], "rdf") 

293 dir_split_number = settings["dir_split_number"] 

294 items_per_file = settings["items_per_file"] 

295 

296 br_dir = os.path.join(rdf_dir, "br") 

297 if not os.path.exists(br_dir): 

298 print(f"Error: BR directory not found at {br_dir}") 

299 return 

300 

301 all_files = collect_zip_files(br_dir, only_data=True) 

302 

303 if not all_files: 

304 print("No BR zip files found") 

305 return 

306 

307 print(f"Processing {len(all_files)} BR files with {args.workers} workers...") 

308 

309 total_brs = 0 

310 all_anomalies: List[dict] = [] 

311 

312 ctx = multiprocessing.get_context("forkserver") 

313 with ctx.Pool( 

314 args.workers, 

315 _init_worker, 

316 (rdf_dir, dir_split_number, items_per_file), 

317 ) as pool: 

318 with create_progress() as progress: 

319 task = progress.add_task("Scanning for anomalies", total=len(all_files)) 

320 for filepath, br_count, anomalies in pool.imap_unordered( 

321 _detect_anomalies_in_file, all_files 

322 ): 

323 total_brs += br_count 

324 all_anomalies.extend(anomalies) 

325 progress.update(task, advance=1) 

326 

327 anomalies_by_type = dict(Counter(a["anomaly_type"] for a in all_anomalies)) 

328 

329 report = { 

330 "config": os.path.abspath(args.config), 

331 "rdf_dir": rdf_dir, 

332 "timestamp": datetime.now(timezone.utc).isoformat(), 

333 "total_brs_analyzed": total_brs, 

334 "total_anomalies": len(all_anomalies), 

335 "anomalies_by_type": anomalies_by_type, 

336 "anomalies": all_anomalies, 

337 } 

338 

339 output_dir = os.path.dirname(os.path.abspath(args.output)) 

340 os.makedirs(output_dir, exist_ok=True) 

341 with open(args.output, "wb") as f: 

342 f.write(orjson.dumps(report, option=orjson.OPT_INDENT_2)) 

343 

344 print(f"Analyzed {total_brs} BRs, found {len(all_anomalies)} anomalies") 

345 for atype, count in sorted(anomalies_by_type.items()): 

346 print(f" {atype}: {count}") 

347 print(f"Report saved to {args.output}") 

348 

349 

350if __name__ == "__main__": 

351 main()