clm-kernel 0.0.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 (333) hide show
  1. clm/__init__.py +230 -0
  2. clm/__main__.py +25 -0
  3. clm/cross_cutting/__init__.py +50 -0
  4. clm/cross_cutting/_test_runner.py +36 -0
  5. clm/cross_cutting/async_utils.py +25 -0
  6. clm/cross_cutting/cel_builder.py +123 -0
  7. clm/cross_cutting/cel_converter.py +697 -0
  8. clm/cross_cutting/cel_engine.py +252 -0
  9. clm/cross_cutting/cel_ops.py +72 -0
  10. clm/cross_cutting/cel_ops_db.py +157 -0
  11. clm/cross_cutting/cel_ops_files.py +99 -0
  12. clm/cross_cutting/cel_ops_state.py +71 -0
  13. clm/cross_cutting/cel_ops_strings.py +64 -0
  14. clm/cross_cutting/cel_resolver.py +63 -0
  15. clm/cross_cutting/cel_slices.py +56 -0
  16. clm/cross_cutting/cel_store.py +330 -0
  17. clm/cross_cutting/clm_logger.py +140 -0
  18. clm/cross_cutting/config/__init__.py +155 -0
  19. clm/cross_cutting/config/config_constants.py +392 -0
  20. clm/cross_cutting/config/env_parameters.py +90 -0
  21. clm/cross_cutting/config/logging.py +378 -0
  22. clm/cross_cutting/config/settings.py +504 -0
  23. clm/cross_cutting/context.py +5 -0
  24. clm/cross_cutting/domain_models.py +55 -0
  25. clm/cross_cutting/domain_types.py +61 -0
  26. clm/cross_cutting/effects.py +67 -0
  27. clm/cross_cutting/errors.py +153 -0
  28. clm/cross_cutting/governance.py +331 -0
  29. clm/cross_cutting/guards.py +41 -0
  30. clm/cross_cutting/mcard.py +321 -0
  31. clm/cross_cutting/native.py +185 -0
  32. clm/cross_cutting/observability.py +289 -0
  33. clm/cross_cutting/pcard.py +434 -0
  34. clm/cross_cutting/registry.py +71 -0
  35. clm/cross_cutting/resources.py +84 -0
  36. clm/cross_cutting/secrets.py +44 -0
  37. clm/cross_cutting/subprocess_utils.py +118 -0
  38. clm/cross_cutting/telemetry.py +164 -0
  39. clm/cross_cutting/testing/__init__.py +8 -0
  40. clm/cross_cutting/testing/comparator.py +187 -0
  41. clm/cross_cutting/testing/datasets.py +87 -0
  42. clm/cross_cutting/timeutil.py +20 -0
  43. clm/cross_cutting/types/new_type_check.py +0 -0
  44. clm/cross_cutting/utils/__init__.py +1 -0
  45. clm/cross_cutting/utils/url_safety.py +50 -0
  46. clm/cross_cutting/vcard.py +560 -0
  47. clm/cross_cutting/writer.py +118 -0
  48. clm/layer0/README.md +50 -0
  49. clm/layer0/__init__.py +223 -0
  50. clm/layer0/algebra.py +60 -0
  51. clm/layer0/algebra_baldwin.py +133 -0
  52. clm/layer0/algebra_baldwin_generators.py +36 -0
  53. clm/layer0/algebra_equivalence.py +262 -0
  54. clm/layer0/algebra_ingest.py +306 -0
  55. clm/layer0/algebra_manifest_io.py +156 -0
  56. clm/layer0/assembler.py +481 -0
  57. clm/layer0/cel/__init__.py +23 -0
  58. clm/layer0/cel_eval.py +491 -0
  59. clm/layer0/codec.py +106 -0
  60. clm/layer0/context.py +122 -0
  61. clm/layer0/db.py +160 -0
  62. clm/layer0/exceptions.py +73 -0
  63. clm/layer0/fibration.py +106 -0
  64. clm/layer0/gates.py +50 -0
  65. clm/layer0/hash.py +80 -0
  66. clm/layer0/loader.py +826 -0
  67. clm/layer0/math_utils.py +163 -0
  68. clm/layer0/mcard.py +96 -0
  69. clm/layer0/model/__init__.py +0 -0
  70. clm/layer0/model/action.py +223 -0
  71. clm/layer0/model/card.py +280 -0
  72. clm/layer0/model/card_triad.py +427 -0
  73. clm/layer0/model/compatibility.py +60 -0
  74. clm/layer0/model/dictionary.py +46 -0
  75. clm/layer0/model/dots.py +331 -0
  76. clm/layer0/model/event_producer.py +88 -0
  77. clm/layer0/model/g_time.py +89 -0
  78. clm/layer0/model/handle.py +163 -0
  79. clm/layer0/model/hash/__init__.py +6 -0
  80. clm/layer0/model/hash/algorithms/__init__.py +3 -0
  81. clm/layer0/model/hash/algorithms/custom_hash.py +7 -0
  82. clm/layer0/model/hash/algorithms/local_sha256.py +178 -0
  83. clm/layer0/model/hash/constants.py +53 -0
  84. clm/layer0/model/hash/enums.py +60 -0
  85. clm/layer0/model/hash/validator.py +307 -0
  86. clm/layer0/model/interpreter.py +275 -0
  87. clm/layer0/model/pagination.py +45 -0
  88. clm/layer0/model/pcard.py +716 -0
  89. clm/layer0/model/schema.py +44 -0
  90. clm/layer0/model/utils/__init__.py +1 -0
  91. clm/layer0/model/utils/content_analyzer.py +152 -0
  92. clm/layer0/model/validators/__init__.py +1 -0
  93. clm/layer0/model/validators/base_validator.py +44 -0
  94. clm/layer0/model/validators/binary_validator.py +66 -0
  95. clm/layer0/model/validators/text_validator.py +82 -0
  96. clm/layer0/model/validators/validation_registry.py +49 -0
  97. clm/layer0/model/vcard.py +823 -0
  98. clm/layer0/model/vcard_ext/__init__.py +39 -0
  99. clm/layer0/model/vcard_ext/core.py +60 -0
  100. clm/layer0/model/vcard_ext/network.py +23 -0
  101. clm/layer0/model/vcard_ext/observability.py +63 -0
  102. clm/layer0/model/vcard_ext/storage.py +60 -0
  103. clm/layer0/model/vcard_ext/vendors.py +211 -0
  104. clm/layer0/model/vcard_sandwich.py +119 -0
  105. clm/layer0/model/vcard_vocabulary.py +463 -0
  106. clm/layer0/model/workflow.py +274 -0
  107. clm/layer0/narrative.py +431 -0
  108. clm/layer0/ontology/collection.py +125 -0
  109. clm/layer0/parser.py +429 -0
  110. clm/layer0/relations.py +73 -0
  111. clm/layer0/rule_evaluator.py +251 -0
  112. clm/layer0/schema/__init__.py +38 -0
  113. clm/layer0/schema/ddl.py +97 -0
  114. clm/layer0/templates/abstract.yaml +99 -0
  115. clm/layer0/templates/balanced.yaml +253 -0
  116. clm/layer0/templates/concrete.yaml +172 -0
  117. clm/layer0/type_registry.py +223 -0
  118. clm/layer0/types.py +204 -0
  119. clm/layer0/utils.py +886 -0
  120. clm/layer0/verifier_core.py +262 -0
  121. clm/layer1/__init__.py +31 -0
  122. clm/layer1/action_dispatcher.py +275 -0
  123. clm/layer1/adapter_registry.py +233 -0
  124. clm/layer1/algebra_baldwin_generators.py +192 -0
  125. clm/layer1/arrow_compiler.py +525 -0
  126. clm/layer1/batch_processor.py +152 -0
  127. clm/layer1/cel/__init__.py +27 -0
  128. clm/layer1/cel/builder.py +173 -0
  129. clm/layer1/engine.py +759 -0
  130. clm/layer1/engine_async.py +89 -0
  131. clm/layer1/eval_callback.py +49 -0
  132. clm/layer1/gate_composer.py +229 -0
  133. clm/layer1/hoare_sandwich.py +298 -0
  134. clm/layer1/identity_guard.py +348 -0
  135. clm/layer1/matrix.py +138 -0
  136. clm/layer1/neural_runtime.py +221 -0
  137. clm/layer1/operations/__init__.py +123 -0
  138. clm/layer1/operations/augment.py +156 -0
  139. clm/layer1/operations/base.py +109 -0
  140. clm/layer1/operations/builtins.py +338 -0
  141. clm/layer1/operations/dispatch.py +144 -0
  142. clm/layer1/operations/exclude.py +107 -0
  143. clm/layer1/operations/handle.py +197 -0
  144. clm/layer1/operations/identity.py +139 -0
  145. clm/layer1/operations/invert.py +66 -0
  146. clm/layer1/operations/loader.py +116 -0
  147. clm/layer1/operations/manipulators/__init__.py +23 -0
  148. clm/layer1/operations/manipulators/document.py +211 -0
  149. clm/layer1/operations/manipulators/media.py +134 -0
  150. clm/layer1/operations/manipulators/structured.py +159 -0
  151. clm/layer1/operations/manipulators/tabular.py +334 -0
  152. clm/layer1/operations/port.py +65 -0
  153. clm/layer1/operations/services.py +529 -0
  154. clm/layer1/operations/split.py +117 -0
  155. clm/layer1/operations/substitute.py +75 -0
  156. clm/layer1/petri.py +1052 -0
  157. clm/layer1/pocketflow_orchestrator.py +408 -0
  158. clm/layer1/pocketflow_scheduler.py +519 -0
  159. clm/layer1/protocols.py +197 -0
  160. clm/layer1/runtime_adapter.py +718 -0
  161. clm/layer1/runtimes/__init__.py +71 -0
  162. clm/layer1/runtimes/_generated_assertions.py +125 -0
  163. clm/layer1/runtimes/_generated_types.py +42 -0
  164. clm/layer1/runtimes/base.py +265 -0
  165. clm/layer1/runtimes/binary.py +101 -0
  166. clm/layer1/runtimes/factory.py +203 -0
  167. clm/layer1/runtimes/javascript.py +321 -0
  168. clm/layer1/runtimes/javascript_runtime.js +33 -0
  169. clm/layer1/runtimes/js_scripts/isomorphism.mjs +22 -0
  170. clm/layer1/runtimes/js_scripts/loader.mjs +52 -0
  171. clm/layer1/runtimes/lambda_calc.py +126 -0
  172. clm/layer1/runtimes/python.py +288 -0
  173. clm/layer1/runtimes/python_runtime.py +41 -0
  174. clm/layer1/runtimes/script.py +114 -0
  175. clm/layer1/sandbox.py +334 -0
  176. clm/layer1/savepoint_guard.py +136 -0
  177. clm/layer1/sparse_net.py +122 -0
  178. clm/layer1/spec_tester.py +165 -0
  179. clm/layer1/store.py +295 -0
  180. clm/layer1/test_runner.py +498 -0
  181. clm/layer1/type_inspector.py +166 -0
  182. clm/layer1/verifier.py +772 -0
  183. clm/layer1/vm.py +91 -0
  184. clm/layer2/__init__.py +78 -0
  185. clm/layer2/card_collection.py +678 -0
  186. clm/layer2/classifier.py +479 -0
  187. clm/layer2/engine/__init__.py +1 -0
  188. clm/layer2/engine/abstract_sql_engine.py +320 -0
  189. clm/layer2/engine/base.py +193 -0
  190. clm/layer2/engine/duckdb_engine.py +576 -0
  191. clm/layer2/engine/sqlite_engine.py +518 -0
  192. clm/layer2/fiber/__init__.py +31 -0
  193. clm/layer2/fiber/context.py +89 -0
  194. clm/layer2/fiber/disposable.py +163 -0
  195. clm/layer2/fiber/fiber.py +125 -0
  196. clm/layer2/fiber/hoare.py +191 -0
  197. clm/layer2/file_io.py +376 -0
  198. clm/layer2/file_registrar.py +77 -0
  199. clm/layer2/ingest.py +202 -0
  200. clm/layer2/lineage.py +322 -0
  201. clm/layer2/mcard_fs.py +262 -0
  202. clm/layer2/merkle.py +11 -0
  203. clm/layer2/mime.py +244 -0
  204. clm/layer2/mime_detector.py +245 -0
  205. clm/layer2/private_collection.py +118 -0
  206. clm/layer2/shared_store.py +11 -0
  207. clm/layer2/storage/__init__.py +190 -0
  208. clm/layer2/storage/db_bitemporal.py +141 -0
  209. clm/layer2/storage/db_driver.py +331 -0
  210. clm/layer2/storage/db_hyperlinks.py +214 -0
  211. clm/layer2/storage/db_pool.py +139 -0
  212. clm/layer2/storage/db_repository.py +229 -0
  213. clm/layer2/storage/savepoint.py +78 -0
  214. clm/layer2/storage/sqlite_wal.py +74 -0
  215. clm/layer2/tridb.py +245 -0
  216. clm/layer2/vcard_receipt.py +71 -0
  217. clm/layer2/vfs.py +17 -0
  218. clm/layer3/__init__.py +141 -0
  219. clm/layer3/agency/__init__.py +19 -0
  220. clm/layer3/agency/config.py +220 -0
  221. clm/layer3/agency/providers/__init__.py +15 -0
  222. clm/layer3/agency/providers/base.py +91 -0
  223. clm/layer3/agency/providers/mlc_llm.py +169 -0
  224. clm/layer3/agency/providers/ollama.py +203 -0
  225. clm/layer3/agency/router.py +71 -0
  226. clm/layer3/agency/runtime.py +524 -0
  227. clm/layer3/gateway.py +353 -0
  228. clm/layer3/open_interpreter_adapter.py +159 -0
  229. clm/layer3/rag/__init__.py +70 -0
  230. clm/layer3/rag/cli.py +228 -0
  231. clm/layer3/rag/config.py +153 -0
  232. clm/layer3/rag/embeddings/__init__.py +16 -0
  233. clm/layer3/rag/embeddings/base.py +79 -0
  234. clm/layer3/rag/embeddings/ollama.py +211 -0
  235. clm/layer3/rag/embeddings/vision.py +290 -0
  236. clm/layer3/rag/engine.py +276 -0
  237. clm/layer3/rag/graph/__init__.py +22 -0
  238. clm/layer3/rag/graph/community.py +198 -0
  239. clm/layer3/rag/graph/engine.py +433 -0
  240. clm/layer3/rag/graph/extractor.py +255 -0
  241. clm/layer3/rag/graph/schema.py +115 -0
  242. clm/layer3/rag/graph/store.py +641 -0
  243. clm/layer3/rag/indexer.py +333 -0
  244. clm/layer3/rag/llm_providers.json +52 -0
  245. clm/layer3/rag/semantic_versioning.py +313 -0
  246. clm/layer3/rag/vector/__init__.py +28 -0
  247. clm/layer3/rag/vector/handle_vector_store.py +749 -0
  248. clm/layer3/rag/vector/schema.py +151 -0
  249. clm/layer3/rag/vector/store.py +632 -0
  250. clm/layer3/reticulum/__init__.py +55 -0
  251. clm/layer3/reticulum/bridge.py +74 -0
  252. clm/layer3/reticulum/daemon.py +88 -0
  253. clm/layer3/reticulum/discovery.py +164 -0
  254. clm/layer3/reticulum/identity.py +107 -0
  255. clm/layer3/reticulum/media.py +148 -0
  256. clm/layer3/reticulum/transport.py +133 -0
  257. clm/layer3/satori/__init__.py +209 -0
  258. clm/layer3/satori/alpha_conversion.py +168 -0
  259. clm/layer3/satori/beta_reduction.py +359 -0
  260. clm/layer3/satori/continuation.py +38 -0
  261. clm/layer3/satori/eta_conversion.py +205 -0
  262. clm/layer3/satori/free_variables.py +163 -0
  263. clm/layer3/satori/io_effects.py +370 -0
  264. clm/layer3/satori/lambda_runtime.py +726 -0
  265. clm/layer3/satori/lambda_term.py +230 -0
  266. clm/layer3/satori/sheaf_audit.py +261 -0
  267. clm/layer3/satori/speech_act.py +279 -0
  268. clm/layer4/README.md +9 -0
  269. clm/layer4/__init__.py +67 -0
  270. clm/layer4/_sovereign_executor.py +580 -0
  271. clm/layer4/action_commands.py +719 -0
  272. clm/layer4/api.py +236 -0
  273. clm/layer4/bootstrap/__init__.py +13 -0
  274. clm/layer4/bootstrap/bootstrapper.py +118 -0
  275. clm/layer4/bootstrap/cleanup.py +44 -0
  276. clm/layer4/bootstrap/clm_hooks.py +49 -0
  277. clm/layer4/bootstrap/genesis.py +77 -0
  278. clm/layer4/bootstrap/injection.py +125 -0
  279. clm/layer4/cli.py +549 -0
  280. clm/layer4/collection_manager.py +553 -0
  281. clm/layer4/commands/__init__.py +81 -0
  282. clm/layer4/commands/action_cmd.py +42 -0
  283. clm/layer4/commands/check_cmd.py +82 -0
  284. clm/layer4/commands/envelope.py +73 -0
  285. clm/layer4/commands/evaluate_cmd.py +224 -0
  286. clm/layer4/commands/mcard_cmd.py +24 -0
  287. clm/layer4/commands/petri_cmd.py +92 -0
  288. clm/layer4/commands/tridb_cmd.py +319 -0
  289. clm/layer4/commands/version_cmd.py +27 -0
  290. clm/layer4/diff_commands.py +167 -0
  291. clm/layer4/eoa_sponsor.py +232 -0
  292. clm/layer4/events.py +69 -0
  293. clm/layer4/flux_gateway.py +133 -0
  294. clm/layer4/gateway.py +91 -0
  295. clm/layer4/loader.py +307 -0
  296. clm/layer4/mcard_commands.py +166 -0
  297. clm/layer4/membrane.py +273 -0
  298. clm/layer4/navigation.py +50 -0
  299. clm/layer4/protocol/protocol_entry_python.py +108 -0
  300. clm/layer4/protocol/python_sync.py +126 -0
  301. clm/layer4/runner.py +150 -0
  302. clm/layer4/satori.py +262 -0
  303. clm/layer4/server/python_sync_client.py +150 -0
  304. clm/layer4/server/python_sync_server.py +139 -0
  305. clm/layer4/server/service_api.py +315 -0
  306. clm/layer4/server/start_servers.py +107 -0
  307. clm/layer4/server/stop_servers.py +24 -0
  308. clm/layer4/simulated_optimization_fix.py +0 -0
  309. clm/layer4/storage.py +463 -0
  310. clm/layer4/tridb_admin.py +268 -0
  311. clm/layer4/websocket/test_ws_client.py +104 -0
  312. clm/layer4/websocket/test_ws_server.py +138 -0
  313. clm/layer4/websocket/ws_server_local.py +160 -0
  314. clm/layer5/__init__.py +51 -0
  315. clm/layer5/async_batch_writer.py +133 -0
  316. clm/layer5/learning.py +276 -0
  317. clm/layer5/telemetry_hook.py +107 -0
  318. clm/layer5/type_lattice.py +238 -0
  319. clm/py.typed +0 -0
  320. clm/schemas/__init__.py +652 -0
  321. clm/schemas/abstract_face_schema.yaml +77 -0
  322. clm/schemas/cel_schema.yaml +416 -0
  323. clm/schemas/lifecycle/__init__.py +45 -0
  324. clm/schemas/lifecycle/models.py +123 -0
  325. clm/schemas/lifecycle/service.py +364 -0
  326. clm/schemas/mcard_schema.sql +192 -0
  327. clm/schemas/mcard_vector_schema.sql +283 -0
  328. clm/schemas/mlp-context-v1.json +91 -0
  329. clm_kernel-0.0.0.dist-info/METADATA +121 -0
  330. clm_kernel-0.0.0.dist-info/RECORD +333 -0
  331. clm_kernel-0.0.0.dist-info/WHEEL +4 -0
  332. clm_kernel-0.0.0.dist-info/entry_points.txt +3 -0
  333. clm_kernel-0.0.0.dist-info/licenses/LICENSE +21 -0
clm/layer0/utils.py ADDED
@@ -0,0 +1,886 @@
1
+ """Shared utility functions and helpers for the CLM Python implementation.
2
+
3
+ This module consolidates common routines for path resolution, hashing,
4
+ environment checks, and system capabilities to avoid code duplication
5
+ and improve maintainability.
6
+
7
+ Hash function upgradeability (INV-02):
8
+ All content hashing routes through ``clm_hash_string`` / ``clm_hash_file``.
9
+ SHA-256 is the current default, but the system is designed to be
10
+ algorithm-agnostic. Stored hash values carry an algorithm prefix
11
+ (e.g. ``sha256:<hex>``) so multiple algorithms can coexist.
12
+ To switch algorithms, change ``HASH_PREFIX`` and the implementation
13
+ of ``clm_hash_string`` — all call sites will pick up the change.
14
+ """
15
+
16
+ import contextlib
17
+ import enum
18
+ import hashlib
19
+ import os
20
+ import platform
21
+ import sys
22
+ from dataclasses import dataclass, field
23
+ from typing import Protocol, runtime_checkable
24
+
25
+ from clm.cross_cutting.clm_logger import get_logger
26
+
27
+ _logger = get_logger("clm.utils")
28
+
29
+
30
+ def normalize_line_endings_bytes(content: bytes) -> bytes:
31
+ """Normalize CRLF or CR line endings to LF in bytes."""
32
+ return content.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
33
+
34
+ def normalize_line_endings_str(content: str) -> str:
35
+ """Normalize CRLF or CR line endings to LF in string."""
36
+ return content.replace("\r\n", "\n").replace("\r", "\n")
37
+
38
+ # ── Hash function infrastructure (INV-02: upgradable hash function) ────────
39
+
40
+ BLAKE3_HASH_PREFIX: str = "blake3:"
41
+ SHA256_HASH_PREFIX: str = "sha256:"
42
+
43
+ #: Default algorithm prefix for stored hash values.
44
+ DEFAULT_HASH_PREFIX: str = BLAKE3_HASH_PREFIX
45
+ HASH_PREFIX: str = DEFAULT_HASH_PREFIX
46
+
47
+ #: Default collision threshold before triggering an algorithm upgrade.
48
+ DEFAULT_COLLISION_THRESHOLD: int = 2
49
+
50
+
51
+ # ── HashProvider protocol (Step 2: provider registry) ──────────────────────
52
+
53
+
54
+ @runtime_checkable
55
+ class HashProvider(Protocol):
56
+ """Protocol for pluggable content-hash algorithms."""
57
+
58
+ def hash(self, content: bytes) -> str:
59
+ """Compute the raw hex digest of content."""
60
+ ...
61
+
62
+ def prefix(self) -> str:
63
+ """Algorithm prefix (e.g. 'sha256:')."""
64
+ ...
65
+
66
+
67
+ # ── Zero-dependency pure Python BLAKE3 engine (INV-02 fallback) ─────────
68
+ _B3_IV = (
69
+ 0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A,
70
+ 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19,
71
+ )
72
+ _B3_MSG_PERM = (2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8)
73
+ _B3_CHUNK_START, _B3_CHUNK_END, _B3_PARENT, _B3_ROOT = 1, 2, 4, 8
74
+
75
+ def _b3_rotr(w: int, r: int) -> int:
76
+ return ((w >> r) | (w << (32 - r))) & 0xFFFFFFFF
77
+
78
+ def _b3_g(s: list, a: int, b: int, c: int, d: int, mx: int, my: int) -> None:
79
+ s[a] = (s[a] + s[b] + mx) & 0xFFFFFFFF
80
+ s[d] = _b3_rotr(s[d] ^ s[a], 16)
81
+ s[c] = (s[c] + s[d]) & 0xFFFFFFFF
82
+ s[b] = _b3_rotr(s[b] ^ s[c], 12)
83
+ s[a] = (s[a] + s[b] + my) & 0xFFFFFFFF
84
+ s[d] = _b3_rotr(s[d] ^ s[a], 8)
85
+ s[c] = (s[c] + s[d]) & 0xFFFFFFFF
86
+ s[b] = _b3_rotr(s[b] ^ s[c], 7)
87
+
88
+ def _b3_round(s: list, m: list) -> None:
89
+ _b3_g(s, 0, 4, 8, 12, m[0], m[1])
90
+ _b3_g(s, 1, 5, 9, 13, m[2], m[3])
91
+ _b3_g(s, 2, 6, 10, 14, m[4], m[5])
92
+ _b3_g(s, 3, 7, 11, 15, m[6], m[7])
93
+ _b3_g(s, 0, 5, 10, 15, m[8], m[9])
94
+ _b3_g(s, 1, 6, 11, 12, m[10], m[11])
95
+ _b3_g(s, 2, 7, 8, 13, m[12], m[13])
96
+ _b3_g(s, 3, 4, 9, 14, m[14], m[15])
97
+
98
+ def _b3_compress(cv: list, block_words: list, block_len: int, counter: int, flags: int) -> list:
99
+ s = list(cv) + list(_B3_IV[:4]) + [counter & 0xFFFFFFFF, (counter >> 32) & 0xFFFFFFFF, block_len, flags]
100
+ m = list(block_words)
101
+ for r in range(7):
102
+ _b3_round(s, m)
103
+ if r < 6:
104
+ m = [m[_B3_MSG_PERM[i]] for i in range(16)]
105
+ out = [0] * 16
106
+ for i in range(8):
107
+ out[i] = (s[i] ^ s[i + 8]) & 0xFFFFFFFF
108
+ out[i + 8] = (s[i + 8] ^ cv[i]) & 0xFFFFFFFF
109
+ return out
110
+
111
+ def _b3_words_from_bytes(b: bytearray) -> list:
112
+ words = [0] * 16
113
+ for i in range(min(16, (len(b) + 3) // 4)):
114
+ chunk = b[i*4 : (i+1)*4]
115
+ words[i] = int.from_bytes(chunk, "little")
116
+ return words
117
+
118
+ def _b3_words_to_hex(words: list) -> str:
119
+ import struct
120
+ return "".join(struct.pack("<I", w).hex() for w in words)
121
+
122
+ class _B3ChunkState:
123
+ def __init__(self, counter: int, flags: int = 0):
124
+ self.cv = list(_B3_IV)
125
+ self.counter = counter
126
+ self.flags = flags
127
+ self.block = bytearray()
128
+ self.blocks_compressed = 0
129
+
130
+ def start_flag(self) -> int:
131
+ return _B3_CHUNK_START if self.blocks_compressed == 0 else 0
132
+
133
+ def update(self, data: bytes) -> None:
134
+ offset = 0
135
+ while offset < len(data):
136
+ if len(self.block) == 64:
137
+ words = _b3_words_from_bytes(self.block)
138
+ out = _b3_compress(self.cv, words, 64, self.counter, self.flags | self.start_flag())
139
+ self.cv = out[:8]
140
+ self.blocks_compressed += 1
141
+ self.block = bytearray()
142
+ take = min(64 - len(self.block), len(data) - offset)
143
+ self.block.extend(data[offset : offset + take])
144
+ offset += take
145
+
146
+ def output(self, is_root: bool = False) -> list:
147
+ words = _b3_words_from_bytes(self.block)
148
+ flags = self.flags | self.start_flag() | _B3_CHUNK_END
149
+ if is_root:
150
+ flags |= _B3_ROOT
151
+ return _b3_compress(self.cv, words, len(self.block), self.counter, flags)
152
+
153
+ def _b3_parent_output(left_cv: list, right_cv: list, is_root: bool = False) -> list:
154
+ block_words = left_cv[:8] + right_cv[:8]
155
+ flags = _B3_PARENT
156
+ if is_root:
157
+ flags |= _B3_ROOT
158
+ return _b3_compress(_B3_IV, block_words, 64, 0, flags)
159
+
160
+ def pure_python_blake3(data: bytes) -> str:
161
+ """Zero-dependency pure Python BLAKE3 hash computation."""
162
+ if isinstance(data, str):
163
+ data = data.encode("utf-8")
164
+ if len(data) <= 1024:
165
+ c = _B3ChunkState(0, 0)
166
+ c.update(data)
167
+ return _b3_words_to_hex(c.output(True)[:8])
168
+
169
+ stack = []
170
+ chunk_counter = 0
171
+ offset = 0
172
+ while offset + 1024 < len(data):
173
+ chunk_bytes = data[offset : offset + 1024]
174
+ c = _B3ChunkState(chunk_counter, 0)
175
+ c.update(chunk_bytes)
176
+ chunk_cv = c.output(False)[:8]
177
+ chunk_counter += 1
178
+ offset += 1024
179
+
180
+ total = chunk_counter
181
+ while (total & 1) == 0:
182
+ left_cv = stack.pop()
183
+ chunk_cv = _b3_parent_output(left_cv, chunk_cv, False)[:8]
184
+ total >>= 1
185
+ stack.append(chunk_cv)
186
+
187
+ final = _B3ChunkState(chunk_counter, 0)
188
+ final.update(data[offset:])
189
+ out = final.output(len(stack) == 0)[:8]
190
+ if len(stack) == 0:
191
+ return _b3_words_to_hex(out)
192
+
193
+ while stack:
194
+ left = stack.pop()
195
+ is_root = (len(stack) == 0)
196
+ out = _b3_parent_output(left, out, is_root)[:8]
197
+ return _b3_words_to_hex(out)
198
+
199
+
200
+ class Blake3Provider:
201
+ """BLAKE3 hash provider (default active algorithm under INV-02)."""
202
+
203
+ def hash(self, content: bytes) -> str:
204
+ try:
205
+ import blake3 # type: ignore
206
+ return blake3.blake3(content).hexdigest()
207
+ except ImportError:
208
+ pass
209
+
210
+ # Try native clm_rust_core binary
211
+ candidates = [
212
+ os.environ.get("CLM_RUST_CORE_BIN"),
213
+ os.path.join(os.path.dirname(__file__), "../../../clm_rust_core/target/release/clm_rust_core"),
214
+ os.path.join(os.path.dirname(__file__), "../../../clm_rust_core/target/debug/clm_rust_core"),
215
+ os.path.join(os.path.dirname(__file__), "../../../../kernel/clm_rust_core/target/debug/clm_rust_core"),
216
+ "clm_rust_core",
217
+ "b3sum",
218
+ ]
219
+ if os.environ.get("CLM_ZERO_FS", "").lower() == "true":
220
+ return pure_python_blake3(content)
221
+
222
+ for bin_path in candidates:
223
+ if not bin_path:
224
+ continue
225
+ try:
226
+ import subprocess
227
+ cmd = [bin_path, "hash"] if "clm_rust_core" in bin_path else [bin_path, "--no-names"]
228
+ proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
229
+ stdout, _ = proc.communicate(input=content)
230
+ if proc.returncode == 0 and stdout:
231
+ out = stdout.decode("utf-8").strip()
232
+ if out:
233
+ return out.split()[0]
234
+ except Exception:
235
+ continue
236
+
237
+ # Zero-dependency pure Python fallback (works in hermetic sandboxes & temp mock roots)
238
+ return pure_python_blake3(content)
239
+
240
+ def prefix(self) -> str:
241
+ return BLAKE3_HASH_PREFIX
242
+
243
+
244
+ class Sha256Provider:
245
+ """Default SHA-256 hash provider."""
246
+
247
+ def hash(self, content: bytes) -> str:
248
+ return hashlib.sha256(content).hexdigest()
249
+
250
+ def prefix(self) -> str:
251
+ return SHA256_HASH_PREFIX
252
+
253
+
254
+ class Md5Provider:
255
+ """MD5 hash provider (weakest in the hierarchy, for legacy compatibility)."""
256
+
257
+ def hash(self, content: bytes) -> str:
258
+ return hashlib.md5(content).hexdigest()
259
+
260
+ def prefix(self) -> str:
261
+ return "md5:"
262
+
263
+
264
+ class Sha1Provider:
265
+ """SHA-1 hash provider (legacy, for migration path compatibility)."""
266
+
267
+ def hash(self, content: bytes) -> str:
268
+ return hashlib.sha1(content).hexdigest()
269
+
270
+ def prefix(self) -> str:
271
+ return "sha1:"
272
+
273
+
274
+ class Sha512Provider:
275
+ """SHA-512 hash provider (stronger than SHA-256)."""
276
+
277
+ def hash(self, content: bytes) -> str:
278
+ return hashlib.sha512(content).hexdigest()
279
+
280
+ def prefix(self) -> str:
281
+ return "sha512:"
282
+
283
+
284
+ class Shake256Provider:
285
+ """Keccak SHAKE-256 provider with configurable output bit-length (custom-N).
286
+
287
+ Uses the SHAKE-256 extendable-output function (XOF) to produce a digest
288
+ of N bits. The prefix is ``custom-N:`` (e.g. ``custom-1024:``).
289
+ This provides future-proofing: if custom-N is exhausted, upgrade to
290
+ custom-2N by registering a new provider with doubled bit-length.
291
+ """
292
+
293
+ def __init__(self, bits: int) -> None:
294
+ self.bits = bits
295
+ self._prefix = f"custom-{bits}:"
296
+
297
+ def hash(self, content: bytes) -> str:
298
+ byte_len = (self.bits + 7) // 8
299
+ return hashlib.shake_256(content).hexdigest(byte_len)
300
+
301
+ def prefix(self) -> str:
302
+ return self._prefix
303
+
304
+
305
+ # Algorithm strength hierarchy (weakest → strongest).
306
+ STRENGTH_HIERARCHY: list[str] = [
307
+ "md5:",
308
+ "sha1:",
309
+ "sha256:",
310
+ "blake3:",
311
+ "sha512:",
312
+ "custom-1024:",
313
+ ]
314
+
315
+
316
+ def next_stronger_algorithm(current: str) -> str:
317
+ """Recommend the next stronger algorithm in the hierarchy.
318
+
319
+ If the current algorithm is ``custom-N``, returns ``custom-2N``
320
+ (recursive doubling). If not in the hierarchy, returns ``blake3:``.
321
+ """
322
+ if current.startswith("custom-") and current.endswith(":"):
323
+ num_str = current[len("custom-"):-1]
324
+ try:
325
+ doubled = int(num_str) * 2
326
+ return f"custom-{doubled}:"
327
+ except ValueError:
328
+ pass
329
+ try:
330
+ pos = STRENGTH_HIERARCHY.index(current)
331
+ if pos + 1 < len(STRENGTH_HIERARCHY):
332
+ return STRENGTH_HIERARCHY[pos + 1]
333
+ except ValueError:
334
+ pass
335
+ return DEFAULT_HASH_PREFIX
336
+
337
+
338
+ def register_custom_provider(bits: int) -> None:
339
+ """Register a custom-N (SHAKE-256) provider with the given bit-length."""
340
+ register_provider(Shake256Provider(bits))
341
+
342
+
343
+ # Provider registry — maps algorithm prefix → provider instance
344
+ _PROVIDER_REGISTRY: dict[str, HashProvider] = {}
345
+
346
+
347
+ def register_provider(provider: HashProvider) -> None:
348
+ """Register a new hash provider under its algorithm prefix."""
349
+ _PROVIDER_REGISTRY[provider.prefix()] = provider
350
+
351
+
352
+ def list_algorithms() -> list[str]:
353
+ """List all registered algorithm prefixes."""
354
+ return list(_PROVIDER_REGISTRY.keys())
355
+
356
+
357
+ # Register the default providers at import time
358
+ register_provider(Blake3Provider())
359
+ register_provider(Sha256Provider())
360
+ register_provider(Md5Provider())
361
+ register_provider(Sha1Provider())
362
+ register_provider(Sha512Provider())
363
+
364
+
365
+ # ── Hash configuration policy (Step 4) ─────────────────────────────────────
366
+
367
+ @dataclass
368
+ class HashConfig:
369
+ """Configuration policy for hash algorithm selection and collision handling.
370
+
371
+ Attributes:
372
+ active_algorithm: Algorithm prefix for new content (e.g. 'blake3:').
373
+ collision_threshold: Number of collisions before triggering an upgrade.
374
+ fallback_order: Ordered list of fallback algorithm prefixes.
375
+ """
376
+ active_algorithm: str = BLAKE3_HASH_PREFIX
377
+ collision_threshold: int = DEFAULT_COLLISION_THRESHOLD
378
+ fallback_order: list[str] = field(default_factory=lambda: [BLAKE3_HASH_PREFIX, SHA256_HASH_PREFIX])
379
+
380
+
381
+ def _load_hash_config() -> HashConfig:
382
+ """Load hash configuration from environment variables.
383
+
384
+ Checks ``CLM_HASH_CONFIG`` (format: ``active|threshold|fallback1,fallback2``).
385
+ If not present, checks ``CLM_HASH_ALGO`` or ``MCARD_HASH_ALGORITHM``.
386
+ Defaults to ``blake3:`` with fallback ``["blake3:", "sha256:"]``.
387
+ """
388
+ raw = os.environ.get("CLM_HASH_CONFIG", "")
389
+ if raw:
390
+ parts = raw.split("|", 2)
391
+ if len(parts) >= 2:
392
+ active = parts[0] if parts[0] else BLAKE3_HASH_PREFIX
393
+ if not active.endswith(":"):
394
+ active = f"{active}:"
395
+ try:
396
+ threshold = int(parts[1])
397
+ except ValueError:
398
+ threshold = DEFAULT_COLLISION_THRESHOLD
399
+ if len(parts) == 3 and parts[2]:
400
+ fallbacks = [s if s.endswith(":") else f"{s}:" for s in parts[2].split(",") if s]
401
+ else:
402
+ fallbacks = [active, SHA256_HASH_PREFIX]
403
+ return HashConfig(
404
+ active_algorithm=active,
405
+ collision_threshold=threshold,
406
+ fallback_order=fallbacks,
407
+ )
408
+
409
+ single_algo = os.environ.get("CLM_HASH_ALGO") or os.environ.get("MCARD_HASH_ALGORITHM")
410
+ if single_algo:
411
+ algo = single_algo.strip().lower()
412
+ if not algo.endswith(":"):
413
+ algo = f"{algo}:"
414
+ return HashConfig(
415
+ active_algorithm=algo,
416
+ collision_threshold=DEFAULT_COLLISION_THRESHOLD,
417
+ fallback_order=[algo, SHA256_HASH_PREFIX] if algo != SHA256_HASH_PREFIX else [SHA256_HASH_PREFIX],
418
+ )
419
+
420
+ return HashConfig()
421
+
422
+
423
+ #: Global hash configuration, loaded once from environment.
424
+ _HASH_CONFIG: HashConfig = _load_hash_config()
425
+
426
+
427
+ def get_hash_config() -> HashConfig:
428
+ """Get the current hash configuration."""
429
+ return _HASH_CONFIG
430
+
431
+
432
+ # ── Prefix-based dispatch (Step 3) ─────────────────────────────────────────
433
+
434
+ def extract_hash_prefix(prefixed: str | bytes) -> str | None:
435
+ """Extract the algorithm prefix from a stored hash string.
436
+
437
+ Returns the prefix (including ':') or None if no prefix is found.
438
+ """
439
+ if isinstance(prefixed, bytes):
440
+ prefixed = prefixed.decode("utf-8", errors="replace")
441
+ pos = prefixed.find(":")
442
+ if pos >= 0:
443
+ return prefixed[: pos + 1]
444
+ return None
445
+
446
+
447
+ def verify_hash(stored_hash: str | bytes, content: str | bytes) -> bool:
448
+ """Verify a stored hash against content by dispatching to the correct provider.
449
+
450
+ Parses the algorithm prefix from ``stored_hash``, looks up the
451
+ corresponding provider, and checks whether the content matches.
452
+
453
+ Returns True if the hash matches, False otherwise.
454
+ """
455
+ sh_str = stored_hash.decode("utf-8", errors="replace") if isinstance(stored_hash, bytes) else str(stored_hash)
456
+ ct_str = content.decode("utf-8", errors="replace") if isinstance(content, bytes) else str(content)
457
+ if ":" not in sh_str and ":" in ct_str:
458
+ stored_hash, content = content, stored_hash
459
+ sh_str, ct_str = ct_str, sh_str
460
+
461
+ prefix = extract_hash_prefix(stored_hash) or HASH_PREFIX
462
+ provider = _PROVIDER_REGISTRY.get(prefix)
463
+ if provider is None:
464
+ _logger.warning(f"Unknown hash algorithm prefix: {prefix} (registered: {list_algorithms()})")
465
+ return False
466
+ content_bytes = content.encode("utf-8") if isinstance(content, str) else content
467
+ computed = provider.hash(content_bytes)
468
+ stored_raw = strip_hash_prefix(stored_hash)
469
+ return computed == stored_raw
470
+
471
+
472
+ # ── Collision detection (Step 5) ───────────────────────────────────────────
473
+
474
+
475
+ class CollisionCheckResult(enum.Enum):
476
+ """Result of a collision check."""
477
+ NO_COLLISION = "no_collision"
478
+ COLLISION = "collision"
479
+ THRESHOLD_EXCEEDED = "threshold_exceeded"
480
+
481
+
482
+ def check_collision(
483
+ hash_value: str,
484
+ new_content: bytes,
485
+ existing_content: bytes | None,
486
+ collision_count: int = 0,
487
+ ) -> tuple[CollisionCheckResult, int]:
488
+ """Check whether a hash collision has occurred.
489
+
490
+ Callers should invoke this *before* inserting a new card into the database.
491
+ If ``existing_content`` is not None and differs from ``new_content``, a
492
+ collision is recorded. When the collision count reaches the configured
493
+ threshold, ``THRESHOLD_EXCEEDED`` is returned.
494
+
495
+ Returns a tuple of (result, new_collision_count).
496
+ """
497
+ if existing_content is None or existing_content == new_content:
498
+ return CollisionCheckResult.NO_COLLISION, collision_count
499
+
500
+ count = collision_count + 1
501
+ cfg = get_hash_config()
502
+ if count >= cfg.collision_threshold:
503
+ return CollisionCheckResult.THRESHOLD_EXCEEDED, count
504
+ return CollisionCheckResult.COLLISION, count
505
+
506
+
507
+ def recommended_fallback_algorithm() -> str:
508
+ """Get the recommended fallback algorithm when threshold is exceeded."""
509
+ cfg = get_hash_config()
510
+ for algo in cfg.fallback_order:
511
+ if algo != cfg.active_algorithm and algo in _PROVIDER_REGISTRY:
512
+ return algo
513
+ return cfg.active_algorithm
514
+
515
+
516
+ # ── Convenience functions ──────────────────────────────────────────────────
517
+
518
+
519
+ def clm_hash_string(content: str | bytes) -> str:
520
+ """Compute the content hash of a string or bytes using the active provider.
521
+
522
+ Dispatches dynamically to the registered provider matching the active algorithm.
523
+ All call sites across the codebase route through this function.
524
+ """
525
+ content_bytes = content.encode("utf-8") if isinstance(content, str) else content
526
+ active = get_hash_config().active_algorithm
527
+ provider = _PROVIDER_REGISTRY.get(active)
528
+ if provider is not None:
529
+ return provider.hash(content_bytes)
530
+ return hashlib.sha256(content_bytes).hexdigest()
531
+
532
+
533
+ def prefixed_hash(content: str | bytes) -> str:
534
+ """Compute the prefixed content hash (e.g. ``blake3:<hex>`` or ``sha256:<hex>``)."""
535
+ cfg = get_hash_config()
536
+ provider = _PROVIDER_REGISTRY.get(cfg.active_algorithm)
537
+ pfx = provider.prefix() if provider is not None else HASH_PREFIX
538
+ return f"{pfx}{clm_hash_string(content)}"
539
+
540
+
541
+ def prefixed_hash_raw(raw_hex: str) -> str:
542
+ """Prepend the active algorithm prefix to an already-computed raw hex digest."""
543
+ cfg = get_hash_config()
544
+ return f"{cfg.active_algorithm}{raw_hex}"
545
+
546
+
547
+ def strip_hash_prefix(prefixed: str | bytes) -> str:
548
+ """Strip the algorithm prefix from a hash string.
549
+
550
+ If the hash starts with a known prefix (e.g. ``blake3:`` or ``sha256:``),
551
+ the unprefixed hex is returned. Otherwise the input is returned as-is.
552
+ """
553
+ if isinstance(prefixed, bytes):
554
+ prefixed = prefixed.decode("utf-8", errors="replace")
555
+ active = get_hash_config().active_algorithm
556
+ if prefixed.startswith(active):
557
+ return prefixed[len(active):]
558
+ if prefixed.startswith(DEFAULT_HASH_PREFIX):
559
+ return prefixed[len(DEFAULT_HASH_PREFIX):]
560
+ if prefixed.startswith(SHA256_HASH_PREFIX):
561
+ return prefixed[len(SHA256_HASH_PREFIX):]
562
+ # Generic fallback: strip any ``<algo>:`` prefix
563
+ if ":" in prefixed:
564
+ return prefixed.split(":", 1)[1]
565
+ return prefixed
566
+
567
+ def clm_hash_file(filepath: str) -> str:
568
+ """Compute the content hash of a file after normalizing line endings."""
569
+ if not os.path.exists(filepath):
570
+ return ""
571
+ with open(filepath, "rb") as f:
572
+ raw = f.read()
573
+ normalized = normalize_line_endings_bytes(raw)
574
+ return clm_hash_string(normalized)
575
+
576
+
577
+ # ── Content-equivalence operator (Gap 2) ──────────────────────────────────
578
+
579
+
580
+ def content_equivalent(db_path: str, hash_a: str, hash_b: str) -> bool:
581
+ """Check whether two hashes point to the same content in the CAS.
582
+
583
+ This is the Python equivalent of the shell ``clm_content_equivalent`` operator.
584
+ Two hashes generated by different algorithms (e.g. ``sha256:abc`` and
585
+ ``sha512:def``) will never match as strings, but they may point to the same
586
+ underlying content. This function resolves both hashes to their stored
587
+ content and compares the raw bytes.
588
+
589
+ Returns ``True`` if both hashes exist and point to identical content,
590
+ ``False`` otherwise.
591
+ """
592
+ if hash_a == hash_b:
593
+ return True
594
+ import sqlite3
595
+ conn = sqlite3.connect(db_path)
596
+ try:
597
+ cursor = conn.cursor()
598
+ cursor.execute("SELECT content FROM card WHERE hash = ?", (hash_a,))
599
+ row_a = cursor.fetchone()
600
+ cursor.execute("SELECT content FROM card WHERE hash = ?", (hash_b,))
601
+ row_b = cursor.fetchone()
602
+ if row_a is None or row_b is None:
603
+ return False
604
+ return row_a[0] == row_b[0]
605
+ finally:
606
+ conn.close()
607
+
608
+
609
+ # ── Handle migration lifecycle (Gap 3) ────────────────────────────────────
610
+
611
+
612
+ def migrate_handle_hash(db_path: str, handle: str, target_algo: str) -> str:
613
+ """Migrate a handle to use a different hash algorithm.
614
+
615
+ This is the Python equivalent of the shell ``clm_migrate_handle_hash`` operator.
616
+ It resolves the handle to its current hash, retrieves the content from the
617
+ CAS, computes a new hash under the target algorithm, and transactionally
618
+ inserts the new card, records handle history, and updates the handle pointer.
619
+
620
+ Args:
621
+ db_path: Path to the content_memory SQLite database.
622
+ handle: The handle name to migrate (e.g. ``test:migration_handle``).
623
+ target_algo: Target algorithm prefix (e.g. ``sha256:``, ``sha512:``,
624
+ ``custom-1024:``). Must be a registered provider.
625
+
626
+ Returns:
627
+ The new hash string (with algorithm prefix).
628
+
629
+ Raises:
630
+ ValueError: If the handle is not found, content is missing, or the
631
+ target algorithm is not registered.
632
+ """
633
+ # Validate target algorithm
634
+ algo = target_algo.rstrip(":")
635
+ if algo not in _PROVIDER_REGISTRY and f"{algo}:" not in _PROVIDER_REGISTRY:
636
+ # Try with colon
637
+ if target_algo in _PROVIDER_REGISTRY:
638
+ algo = target_algo
639
+ else:
640
+ raise ValueError(
641
+ f"Unsupported target algorithm: '{target_algo}' "
642
+ f"(registered: {list_algorithms()})"
643
+ )
644
+
645
+ import sqlite3
646
+ from datetime import datetime, timezone
647
+
648
+ conn = sqlite3.connect(db_path)
649
+ try:
650
+ cursor = conn.cursor()
651
+
652
+ # 1. Resolve handle to get current hash
653
+ cursor.execute(
654
+ "SELECT current_hash FROM handle_registry WHERE handle = ?",
655
+ (handle,),
656
+ )
657
+ row = cursor.fetchone()
658
+ if row is None:
659
+ raise ValueError(f"Handle '{handle}' not found in handle_registry")
660
+ current_hash = row[0]
661
+
662
+ # 2. Retrieve existing content
663
+ cursor.execute(
664
+ "SELECT content FROM card WHERE hash = ?", (current_hash,)
665
+ )
666
+ row = cursor.fetchone()
667
+ if row is None:
668
+ raise ValueError(
669
+ f"Content not found in CAS for hash '{current_hash}'"
670
+ )
671
+ content = row[0]
672
+
673
+ # 3. Compute new hash under target algorithm
674
+ provider_key = target_algo if target_algo in _PROVIDER_REGISTRY else f"{algo}:"
675
+ provider = _PROVIDER_REGISTRY.get(provider_key)
676
+ if provider is None:
677
+ raise ValueError(f"Provider not found for algorithm '{target_algo}'")
678
+ new_raw_hash = provider.hash(content if isinstance(content, bytes) else content.encode("utf-8"))
679
+ new_hash = f"{provider.prefix()}{new_raw_hash}"
680
+
681
+ # 4. Transactional update with retry for WAL lock contention
682
+ now_utc = datetime.now(timezone.utc).isoformat()
683
+ g_time = f"{algo}|{now_utc}|UTC"
684
+
685
+ import time as _time
686
+ max_attempts = 3
687
+ last_err: Exception | None = None
688
+ for attempt in range(max_attempts):
689
+ try:
690
+ conn.execute("BEGIN TRANSACTION")
691
+ conn.execute(
692
+ "INSERT OR IGNORE INTO card (hash, content, g_time) VALUES (?, ?, ?)",
693
+ (new_hash, content, g_time),
694
+ )
695
+ conn.execute(
696
+ "INSERT INTO handle_history (handle, previous_hash, changed_at) VALUES (?, ?, ?)",
697
+ (handle, current_hash, now_utc),
698
+ )
699
+ conn.execute(
700
+ "UPDATE handle_registry SET current_hash = ?, updated_at = ? WHERE handle = ?",
701
+ (new_hash, now_utc, handle),
702
+ )
703
+ conn.execute("COMMIT")
704
+
705
+ _logger.info(
706
+ f"migrate_handle_hash: migrated '{handle}' from {current_hash} to {new_hash}"
707
+ )
708
+ return new_hash
709
+ except Exception as retry_err:
710
+ last_err = retry_err
711
+ with contextlib.suppress(Exception):
712
+ conn.execute("ROLLBACK")
713
+ if attempt + 1 < max_attempts:
714
+ _time.sleep(0.2)
715
+
716
+ raise RuntimeError(
717
+ f"Transaction failed after {max_attempts} attempts: {last_err}"
718
+ )
719
+ finally:
720
+ conn.close()
721
+
722
+ def resolve_clm_path(clm_root: str, ref: str) -> str:
723
+ """Resolve a relative CLM path referencing files/directories to absolute path."""
724
+ if not ref:
725
+ return ref
726
+ if os.path.isabs(ref):
727
+ return ref
728
+ if os.path.exists(ref):
729
+ return os.path.abspath(ref)
730
+
731
+ cand = os.path.join(clm_root, ref)
732
+ if os.path.exists(cand):
733
+ return os.path.abspath(cand)
734
+
735
+ project_root = os.path.dirname(clm_root)
736
+ cand = os.path.join(project_root, ref)
737
+ if os.path.exists(cand):
738
+ return os.path.abspath(cand)
739
+
740
+ if ref.startswith("clm/"):
741
+ stripped = ref[4:]
742
+ cand = os.path.join(clm_root, stripped)
743
+ if os.path.exists(cand):
744
+ return os.path.abspath(cand)
745
+
746
+ return ref
747
+
748
+ def detect_host_os() -> str:
749
+ """Detect and return host OS platform in lowercase (linux, darwin, windows, etc.)."""
750
+ raw_os = platform.system().lower()
751
+ if "linux" in raw_os:
752
+ return "linux"
753
+ elif "darwin" in raw_os:
754
+ return "darwin"
755
+ elif "windows" in raw_os:
756
+ return "windows"
757
+ return "unknown"
758
+
759
+ def is_docker_live(port: int) -> bool:
760
+ """Check if docker daemon is responsive and a service is open at localhost:port."""
761
+ if os.environ.get("CLM_ZERO_FS", "").lower() == "true":
762
+ return False
763
+ try:
764
+ import socket
765
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
766
+ s.settimeout(0.5)
767
+ s.connect(("127.0.0.1", port))
768
+ s.close()
769
+ except Exception:
770
+ return False
771
+ try:
772
+ import subprocess
773
+ proc = subprocess.Popen(["docker", "info"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
774
+ try:
775
+ proc.communicate(timeout=2)
776
+ except subprocess.TimeoutExpired:
777
+ proc.kill()
778
+ proc.communicate()
779
+ return False
780
+ if proc.returncode != 0:
781
+ return False
782
+ except Exception:
783
+ return False
784
+
785
+ if port == 9000:
786
+ try:
787
+ proc = subprocess.Popen(["docker", "exec", "minio_server", "mc", "alias", "list", "local"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
788
+ try:
789
+ proc.communicate(timeout=2)
790
+ except subprocess.TimeoutExpired:
791
+ proc.kill()
792
+ proc.communicate()
793
+ return False
794
+ return proc.returncode == 0
795
+ except Exception:
796
+ return False
797
+ elif port == 5001:
798
+ try:
799
+ proc = subprocess.Popen(["docker", "exec", "ipfs_host", "ipfs", "id"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
800
+ try:
801
+ proc.communicate(timeout=2)
802
+ except subprocess.TimeoutExpired:
803
+ proc.kill()
804
+ proc.communicate()
805
+ return False
806
+ return proc.returncode == 0
807
+ except Exception:
808
+ return False
809
+
810
+ return True
811
+
812
+ def get_bash_command() -> str:
813
+ """Resolve the bash executable path on the current platform, preferring Git Bash on Windows."""
814
+ if sys.platform == "win32":
815
+ bash_env = os.environ.get("BASH")
816
+ if bash_env and os.path.exists(bash_env):
817
+ return bash_env
818
+ git_bash = r"C:\Program Files\Git\bin\bash.exe"
819
+ if os.path.exists(git_bash):
820
+ return git_bash
821
+ return "bash"
822
+
823
+
824
+ def verify_file_hash(filepath: str, expected_hash: str) -> bytes:
825
+ """Read a file, normalize line endings, verify SHA-256, and return normalized content."""
826
+ if not os.path.exists(filepath):
827
+ sys.exit(f"FATAL: Manifest missing at {filepath}")
828
+
829
+ with open(filepath, "rb") as fh:
830
+ raw = fh.read()
831
+
832
+ normalized = normalize_line_endings_bytes(raw)
833
+ computed = hashlib.sha256(normalized).hexdigest()
834
+
835
+ if computed != expected_hash:
836
+ _logger.error(" INTEGRITY FAILURE: file content has changed")
837
+ _logger.error(f" Expected: {expected_hash}")
838
+ _logger.error(f" Actual: {computed}")
839
+ sys.exit(1)
840
+
841
+ return normalized
842
+
843
+
844
+ def extract_python_script_from_yaml(normalized_content: bytes) -> str:
845
+ """Extract the indented Python bootstrap script from a clmEval.yaml payload."""
846
+ lines = normalized_content.decode("utf-8").splitlines()
847
+ in_python_block = False
848
+ in_script = False
849
+ script_lines = []
850
+ indent_len = None
851
+
852
+ for line in lines:
853
+ stripped = line.strip()
854
+ if not in_python_block:
855
+ if stripped == "- type: python":
856
+ in_python_block = True
857
+ else:
858
+ if stripped.startswith("- type:"):
859
+ break
860
+ if stripped.startswith("balanced_exp:"):
861
+ break
862
+ if not in_script:
863
+ if stripped.startswith("script:"):
864
+ in_script = True
865
+ else:
866
+ if line:
867
+ leading_spaces = len(line) - len(line.lstrip(' '))
868
+ if indent_len is None:
869
+ indent_len = leading_spaces
870
+ if leading_spaces < indent_len:
871
+ break
872
+ if indent_len is not None:
873
+ if len(line) >= indent_len:
874
+ script_lines.append(line[indent_len:])
875
+ else:
876
+ script_lines.append("")
877
+ else:
878
+ if len(line) >= 8:
879
+ script_lines.append(line[8:])
880
+ else:
881
+ script_lines.append("")
882
+
883
+ if not script_lines:
884
+ sys.exit("FATAL: Could not extract Python bootstrap script.")
885
+
886
+ return "\n".join(script_lines)