Skip to content

Commit 57a96cb

Browse files
author
Douglas Jones
committed
v3.0: parallel imports, remote symbols, refusal reasons, believe multiline fix
V3-1: parallel evaluator full import support (AUD-OVERNIGHT-02 fix) - Branch interpreters receive resolved_imports - should_parallelize threshold updated for imported symbols - 2 regression tests V3-2: remote symbol resolution - RemoteStore with fetch-and-cache and hash-verification - codifide serve --read-only for public registry deployments - codifide store push --registry, codifide run --registry - 16 new tests V3-3: refusal reasons (bottom "reason") - BottomExpr gains optional reason field - BottomWithReason runtime value; RefusalError.reason propagated - Canonical JSON: reason key emitted only when present (backward-compatible) - Rust: Expr::Bottom{reason}, Value::Bottom{reason}, Error::Refusal{reason} - 24 new tests FIND-G1: believe arm value on continuation line - Python and Rust parsers: empty RHS after => gathers next indented line - Supports multi-line if/then/else as arm values - 3 regression tests __version__ bumped to 3.0.0 386 tests passing
1 parent dbbe164 commit 57a96cb

28 files changed

Lines changed: 1573 additions & 137 deletions

CHANGELOG.md

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,37 @@
33
All notable changes to Codifide are recorded here. Releases follow semver once we
44
reach v1.0; until then, the canonical form may change between minor versions.
55

6-
# Codifide Changelog
6+
## [3.0.0] — 2026-05-14
77

8-
All notable changes to Codifide are recorded here. Releases follow semver once we
9-
reach v1.0; until then, the canonical form may change between minor versions.
8+
Three requirements shipped (V3-1, V3-2, V3-3). V3-4 (time-indexed types) deferred
9+
— no adoption evidence emerged from V3-1 through V3-3. 383 tests passing, 0 skipped.
10+
11+
### Added — V3-3: Refusal reasons (`bottom "reason"`)
12+
13+
`bottom` gains an optional string payload. `bottom "confidence below threshold"`
14+
parses, evaluates, and propagates the reason through `RefusalError`. Bare `bottom`
15+
is unchanged — its canonical bytes are identical to the pre-V3-3 form.
16+
17+
- **`BottomExpr(reason=...)`** — AST node gains optional `reason` field
18+
- **`BottomWithReason`** — runtime value subclassing `_BottomType`; falsy, passes `isinstance` checks
19+
- **`RefusalError.reason`** — carries the string payload; included in the error message
20+
- **Canonical JSON/CBOR**`"reason"` key emitted only when present; additive, no existing hash invalidated
21+
- **Capability manifest**`bottom` AST kind documents the optional `reason` field
22+
- **Rust**`Expr::Bottom { reason }`, `Value::Bottom { reason }`, `Error::Refusal { reason }` all updated
23+
- 24 new tests in `tests/test_bottom_reason.py`
24+
25+
### Added — V3-2: Remote symbol resolution (shipped previous session)
26+
27+
`RemoteStore` with fetch-and-cache and hash-verification. `codifide serve --read-only`
28+
for public registry deployments. `codifide store push` and `codifide run --registry`.
29+
See `docs/RPC_API.md` §V3-2.
30+
31+
### Added — V3-1: Parallel evaluator full import support (shipped previous session)
32+
33+
Branch interpreters now receive `resolved_imports`. Imported-symbol calls are
34+
eligible for parallel evaluation. AUD-OVERNIGHT-02 fix.
35+
36+
---
1037

1138
## [2.0.0] — 2026-05-14
1239

codifide/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
Value,
1616
Belief,
1717
Bottom,
18+
BottomWithReason,
1819
EffectSet,
1920
)
2021
from .parser.parser import parse
@@ -50,7 +51,7 @@
5051
BottomPropagationError,
5152
)
5253

53-
__version__ = "1.0.0"
54+
__version__ = "3.0.0"
5455

5556
__all__ = [
5657
"Module",
@@ -61,6 +62,7 @@
6162
"Value",
6263
"Belief",
6364
"Bottom",
65+
"BottomWithReason",
6466
"EffectSet",
6567
"parse",
6668
"run",

codifide/__main__.py

Lines changed: 133 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ def _cmd_run_rust(args: argparse.Namespace) -> int:
139139

140140
def _cmd_run_python(args: argparse.Namespace) -> int:
141141
"""Original Python tree-walking interpreter."""
142+
from .store.remote import RemoteStore
143+
142144
try:
143145
src = _read(args.file)
144146
store: Optional[SymbolStore] = None
@@ -149,8 +151,18 @@ def _cmd_run_python(args: argparse.Namespace) -> int:
149151
module = parse(src, store=store)
150152
if store is None and module.imports:
151153
store = SymbolStore(_store_root(args))
154+
155+
# Wrap with RemoteStore if --registry is set (V3-2).
156+
effective_store = store
157+
registry = getattr(args, "registry", None)
158+
if registry and store is not None:
159+
effective_store = RemoteStore(store, registry=registry)
160+
elif registry and module.imports:
161+
local = SymbolStore(_store_root(args))
162+
effective_store = RemoteStore(local, registry=registry)
163+
152164
entry = args.entry or _default_entry(module)
153-
result = run(module, entry, store=store)
165+
result = run(module, entry, store=effective_store)
154166
if result is not None:
155167
print(result)
156168
return 0
@@ -468,6 +480,90 @@ def cmd_store_verify(args: argparse.Namespace) -> int:
468480
return 0
469481

470482

483+
def cmd_store_push(args: argparse.Namespace) -> int:
484+
"""Push a locally-stored symbol to a remote registry.
485+
486+
Reads the symbol bytes from the local store, POSTs them to the
487+
registry's POST /symbols endpoint, and verifies the returned
488+
identity matches. Idempotent: a second push of the same symbol
489+
returns 200 with the existing identity.
490+
491+
The registry URL defaults to https://codifide.com. Agents running
492+
private registries pass --registry https://my-registry.example.com.
493+
"""
494+
import urllib.error
495+
import urllib.request
496+
497+
registry = (args.registry or "https://codifide.com").rstrip("/")
498+
identity = args.identity
499+
500+
# Validate identity shape before touching the store.
501+
if not (identity.startswith("sha256:") and len(identity) == 71):
502+
print(
503+
f"codifide: invalid identity {identity!r}; "
504+
f"expected sha256: followed by 64 hex chars",
505+
file=sys.stderr,
506+
)
507+
return 1
508+
509+
try:
510+
store = SymbolStore(_store_root(args))
511+
data = store.get_bytes(identity)
512+
except StoreError as e:
513+
print(f"codifide: {e}", file=sys.stderr)
514+
return 1
515+
516+
url = f"{registry}/symbols"
517+
req = urllib.request.Request(
518+
url,
519+
data=data,
520+
method="POST",
521+
headers={"Content-Type": "application/cbor"},
522+
)
523+
try:
524+
with urllib.request.urlopen(req, timeout=30) as resp:
525+
body = resp.read(1024 * 1024)
526+
except urllib.error.HTTPError as exc:
527+
body = exc.read(4096)
528+
try:
529+
import json as _json
530+
err = _json.loads(body)
531+
detail = err.get("detail", err.get("error", str(exc)))
532+
except Exception:
533+
detail = body.decode("utf-8", errors="replace")
534+
print(
535+
f"codifide: registry {url} returned HTTP {exc.code}: {detail}",
536+
file=sys.stderr,
537+
)
538+
return 1
539+
except urllib.error.URLError as exc:
540+
print(
541+
f"codifide: cannot reach registry {registry}: {exc.reason}",
542+
file=sys.stderr,
543+
)
544+
return 1
545+
546+
try:
547+
import json as _json
548+
result = _json.loads(body)
549+
except Exception as exc:
550+
print(f"codifide: cannot parse registry response: {exc}", file=sys.stderr)
551+
return 1
552+
553+
returned_identity = result.get("identity", "")
554+
if returned_identity != identity:
555+
print(
556+
f"codifide: registry returned identity {returned_identity!r} "
557+
f"but expected {identity!r}; refusing to accept",
558+
file=sys.stderr,
559+
)
560+
return 1
561+
562+
name = result.get("name", "?")
563+
print(f"{identity}\t{name}")
564+
return 0
565+
566+
471567
def cmd_store_gc(args: argparse.Namespace) -> int:
472568
"""Report or delete unreachable identities.
473569
@@ -644,11 +740,15 @@ def cmd_serve(args: argparse.Namespace) -> int:
644740
Thin HTTP wrapper over the symbol store. See ``docs/RPC_API.md``.
645741
Binds to 127.0.0.1 by default — not safe to expose over a network
646742
without a reverse proxy with TLS and auth.
743+
744+
Pass ``--read-only`` to disable POST /symbols for public registry
745+
deployments (V3-2).
647746
"""
648747
from .server import serve
649748

650749
store = SymbolStore(_store_root(args))
651-
serve(store, host=args.host, port=args.port)
750+
read_only = getattr(args, "read_only", False)
751+
serve(store, host=args.host, port=args.port, read_only=read_only)
652752
return 0
653753

654754

@@ -820,6 +920,13 @@ def main(argv=None) -> int:
820920
"--store",
821921
help="store root for import resolution (default: $CODIFIDE_STORE or ~/.codifide/store)",
822922
)
923+
p_run.add_argument(
924+
"--registry",
925+
default=None,
926+
help="remote registry URL for resolving imports on cache miss "
927+
"(e.g. https://codifide.com). Opt-in: without this flag, "
928+
"only the local store is used.",
929+
)
823930
p_run.set_defaults(func=cmd_run)
824931

825932
p_can = sub.add_parser("canonical", help="print canonical JSON or CBOR")
@@ -940,12 +1047,27 @@ def main(argv=None) -> int:
9401047
)
9411048
p_index.set_defaults(func=cmd_store_index)
9421049

943-
p_verify = store_sub.add_parser(
1050+
p_verify_store = store_sub.add_parser(
9441051
"verify",
9451052
help="verify a stored module's imports resolve in the store",
9461053
)
947-
p_verify.add_argument("hash", help="identity of the module to verify")
948-
p_verify.set_defaults(func=cmd_store_verify)
1054+
p_verify_store.add_argument("hash", help="identity of the module to verify")
1055+
p_verify_store.set_defaults(func=cmd_store_verify)
1056+
1057+
p_push = store_sub.add_parser(
1058+
"push",
1059+
help="push a locally-stored symbol to a remote registry",
1060+
)
1061+
p_push.add_argument(
1062+
"identity",
1063+
help="sha256:<hex> identity of the symbol to push",
1064+
)
1065+
p_push.add_argument(
1066+
"--registry",
1067+
default=None,
1068+
help="registry URL (default: https://codifide.com)",
1069+
)
1070+
p_push.set_defaults(func=cmd_store_push)
9491071

9501072
# -- Garbage collection (2026-05-11 design dispatch) --------------
9511073
p_gc = store_sub.add_parser(
@@ -1003,6 +1125,12 @@ def main(argv=None) -> int:
10031125
"--store",
10041126
help="store root directory (default: $CODIFIDE_STORE or ~/.codifide/store)",
10051127
)
1128+
p_serve.add_argument(
1129+
"--read-only",
1130+
action="store_true",
1131+
dest="read_only",
1132+
help="disable POST /symbols — for public registry deployments (V3-2)",
1133+
)
10061134
p_serve.set_defaults(func=cmd_serve)
10071135

10081136
args = parser.parse_args(argv)

codifide/capability.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,17 @@ def _ast_kinds() -> Dict[str, Any]:
157157
"First-class refusal. Callers must handle it in a "
158158
"``believe`` arm or at a call site; an unhandled "
159159
"``bottom`` propagating to the top-level caller "
160-
"raises ``RefusalError``."
160+
"raises ``RefusalError``. The optional ``reason`` "
161+
"field (V3-3) carries a human-readable explanation "
162+
"of why the refusal occurred; it is propagated "
163+
"through ``RefusalError`` for diagnostics but does "
164+
"not affect dispatch or canonical identity. Bare "
165+
"``bottom`` (no reason) is backward-compatible — "
166+
"its canonical bytes are unchanged."
161167
),
162-
"fields": [],
168+
"fields": [
169+
{"name": "reason", "type": "string", "optional": True},
170+
],
163171
},
164172
"concat": {
165173
"description": (

codifide/core/types.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,16 @@ class Believe:
9090

9191
@dataclass(frozen=True)
9292
class BottomExpr:
93-
"""First-class refusal. Callers must handle; it is not an exception."""
93+
"""First-class refusal. Callers must handle; it is not an exception.
94+
95+
The optional ``reason`` field (added V3-3) carries a human-readable
96+
explanation of why the refusal occurred. It is purely informational:
97+
the runtime propagates it through ``RefusalError`` so callers can
98+
surface it in diagnostics, but it does not affect dispatch or
99+
canonical identity. Bare ``bottom`` (no reason) is backward-compatible
100+
— its canonical bytes are unchanged.
101+
"""
102+
reason: Optional[str] = None
94103
kind: str = field(default="bottom", init=False)
95104

96105

@@ -314,3 +323,27 @@ def __bool__(self) -> bool: # Refusal is not truthy.
314323

315324

316325
Bottom = _BottomType()
326+
327+
328+
class BottomWithReason(_BottomType):
329+
"""A refusal value carrying a human-readable reason string (V3-3).
330+
331+
Subclasses ``_BottomType`` so that ``isinstance(x, _BottomType)``
332+
catches both bare ``Bottom`` and reasoned refusals. The ``reason``
333+
field is purely informational — it does not affect dispatch, canonical
334+
identity, or truthiness.
335+
"""
336+
337+
def __new__(cls, reason: str) -> "BottomWithReason":
338+
# Do NOT use the singleton pattern from _BottomType; each
339+
# BottomWithReason is a distinct instance carrying its own reason.
340+
return object.__new__(cls)
341+
342+
def __init__(self, reason: str) -> None:
343+
self.reason = reason
344+
345+
def __repr__(self) -> str:
346+
return f"⊥({self.reason!r})"
347+
348+
def __bool__(self) -> bool:
349+
return False

codifide/parser/expr_parser.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,11 @@ def parse_atom(self) -> Expr:
256256
if tok.kind == "ident":
257257
if tok.text == "bottom":
258258
self.take()
259+
# Optional reason string: bottom "reason text"
260+
nxt = self.peek()
261+
if nxt is not None and nxt.kind == "str":
262+
self.take()
263+
return BottomExpr(reason=nxt.text)
259264
return BottomExpr()
260265
if tok.text == "true":
261266
self.take()

codifide/parser/parser.py

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -673,19 +673,40 @@ def _parse_believe(lines: List[_Line], i: int) -> Tuple[Expr, int]:
673673
if text.startswith("else") and ("=>" in text or "⇒" in text):
674674
op = "=>" if "=>" in text else "⇒"
675675
_, right = text.split(op, 1)
676-
otherwise = _safe_parse_expr(right.strip(), line.lineno)
677-
i += 1
676+
right = right.strip()
677+
if right:
678+
otherwise = _safe_parse_expr(right, line.lineno)
679+
i += 1
680+
else:
681+
# Value is on the next line — gather it.
682+
if i + 1 >= len(lines) or lines[i + 1].indent <= base_indent:
683+
raise ParseError(
684+
"believe `else =>` arm has no value. "
685+
"Put the value on the same line as `=>` or on the next indented line.",
686+
line=line.lineno,
687+
)
688+
right_text, i = _gather_expr(lines, i + 1, lines[i + 1].text)
689+
otherwise = _safe_parse_expr(right_text, line.lineno)
678690
continue
679691
if "=>" in text or "⇒" in text:
680692
op = "=>" if "=>" in text else "⇒"
681693
left, right = text.split(op, 1)
682-
arms.append(
683-
(
684-
_safe_parse_expr(left.strip(), line.lineno),
685-
_safe_parse_expr(right.strip(), line.lineno),
686-
)
687-
)
688-
i += 1
694+
right = right.strip()
695+
left_expr = _safe_parse_expr(left.strip(), line.lineno)
696+
if right:
697+
right_expr = _safe_parse_expr(right, line.lineno)
698+
i += 1
699+
else:
700+
# Value is on the next line — gather it.
701+
if i + 1 >= len(lines) or lines[i + 1].indent <= base_indent:
702+
raise ParseError(
703+
"believe arm has no value after `=>`. "
704+
"Put the value on the same line as `=>` or on the next indented line.",
705+
line=line.lineno,
706+
)
707+
right_text, i = _gather_expr(lines, i + 1, lines[i + 1].text)
708+
right_expr = _safe_parse_expr(right_text, line.lineno)
709+
arms.append((left_expr, right_expr))
689710
continue
690711
raise ParseError(
691712
f"unexpected line in believe block: {line.raw!r}", line=line.lineno

0 commit comments

Comments
 (0)