fix: break rlm_completion <-> rlm_direct import cycle (#328) #368

Closed
nsaspy wants to merge 2 commits from fix/328-direct-user-namespace-text-string into main
Owner

Why

Issue #328: rlm_completion and rlm_direct import from each other. Under the canonical load order (rlm_completion triggers rlm_direct's load from its own use_module(rlm_direct, ...) directive), rlm_direct imports helpers from a mid-load rlm_completion. For any import whose predicate is not yet defined, SWI resolves against the host user module and registers the import on rlm_completion itself, which then rejects the local definition (No permission to redefine imported_procedure). A host that legitimately defines user:text_string/2 therefore:

  • gets load-time ERROR noise,
  • loses rlm_completion's local text_string/2,
  • silently loses the structured completion_fault(expected_text(Value)) clause — invalid values fail plainly instead of raising the fault.

Audited beyond the reported instance: user:require_options/1 and user:zero_usage/1 corrupt rlm_completion identically. The defect is the load-time cycle, not one helper.

Change

  • new rlm_text.pl leaf module: owns text_string/2 with the exact three-clause contract (string passes through, atom converts, anything else throws completion_fault(expected_text(Value))). Both runtimes import it; rlm_completion re-exports it for its public API; the leaf imports nothing from the runtime.
  • rlm_direct imports text_string/2 from the leaf instead of from rlm_completion.
  • rlm_completion's use_module(rlm_direct, [rlm_direct_model_step/10]) moves to the end of the file with the cycle-boundary rationale: rlm_direct's helper imports must never observe a mid-load rlm_completion.
  • Fresh-process hostile-host regression (test/support/issue328_hostile_host.pl + test/rlm_completion_module_boundary_test.pl, registered in the deterministic corpus) asserting, across library(rlm), rlm_completion-first, and rlm_direct-first load orders:
    • no redefine imported_procedure error,
    • text_string/2, require_options/1, zero_usage/1 stay owned by rlm_completion (never imported_from(user)),
    • rlm_direct_model_step/10 still imports from rlm_direct,
    • string/atom normalization works, invalid structured values still raise completion_fault(expected_text(...)) from both runtimes.

Non-goals

  • No deduplication of the other modules' fault vocabularies (skill_fault, chain_schema_fault, mcp_model_fault, ... keep their module-local semantics).
  • No broader reorganization of the 24 remaining rlm_direct <- rlm_completion helper imports; they are protected by the load-order boundary and the regression, not moved.

Verification (exact head e4113ca)

  • test/run_tests.pl: 1142/1142 passed (includes 3 new boundary tests, fresh-process)
  • test/check_runtime.pl, test/load_all.pl: pass
  • benchmark/run.pl -- deterministic and -- deep-experiment: pass
  • scripts/validate_research_approval.pl, scripts/design_gate.pl: pass
  • clean copied-pack install + load from outside the checkout: pass (new module travels with cp -R prolog)
  • bin/prolog-rlm.pl -- demo --json: pass; git diff --check: clean
## Why Issue #328: `rlm_completion` and `rlm_direct` import from each other. Under the canonical load order (`rlm_completion` triggers `rlm_direct`'s load from its own `use_module(rlm_direct, ...)` directive), `rlm_direct` imports helpers from a **mid-load** `rlm_completion`. For any import whose predicate is not yet defined, SWI resolves against the host `user` module and registers the import on `rlm_completion` itself, which then **rejects the local definition** (`No permission to redefine imported_procedure`). A host that legitimately defines `user:text_string/2` therefore: - gets load-time ERROR noise, - loses `rlm_completion`'s local `text_string/2`, - silently loses the structured `completion_fault(expected_text(Value))` clause — invalid values fail plainly instead of raising the fault. Audited beyond the reported instance: `user:require_options/1` and `user:zero_usage/1` corrupt `rlm_completion` identically. The defect is the load-time cycle, not one helper. ## Change - new **`rlm_text.pl` leaf module**: owns `text_string/2` with the exact three-clause contract (string passes through, atom converts, anything else throws `completion_fault(expected_text(Value))`). Both runtimes import it; `rlm_completion` re-exports it for its public API; the leaf imports nothing from the runtime. - `rlm_direct` imports `text_string/2` from the leaf instead of from `rlm_completion`. - `rlm_completion`'s `use_module(rlm_direct, [rlm_direct_model_step/10])` moves to the end of the file with the cycle-boundary rationale: `rlm_direct`'s helper imports must never observe a mid-load `rlm_completion`. - Fresh-process hostile-host regression (`test/support/issue328_hostile_host.pl` + `test/rlm_completion_module_boundary_test.pl`, registered in the deterministic corpus) asserting, across `library(rlm)`, `rlm_completion`-first, and `rlm_direct`-first load orders: - no `redefine imported_procedure` error, - `text_string/2`, `require_options/1`, `zero_usage/1` stay owned by `rlm_completion` (never `imported_from(user)`), - `rlm_direct_model_step/10` still imports from `rlm_direct`, - string/atom normalization works, invalid structured values still raise `completion_fault(expected_text(...))` from both runtimes. ## Non-goals - No deduplication of the other modules' fault vocabularies (`skill_fault`, `chain_schema_fault`, `mcp_model_fault`, ... keep their module-local semantics). - No broader reorganization of the 24 remaining `rlm_direct <- rlm_completion` helper imports; they are protected by the load-order boundary and the regression, not moved. ## Verification (exact head e4113ca) - `test/run_tests.pl`: 1142/1142 passed (includes 3 new boundary tests, fresh-process) - `test/check_runtime.pl`, `test/load_all.pl`: pass - `benchmark/run.pl -- deterministic` and `-- deep-experiment`: pass - `scripts/validate_research_approval.pl`, `scripts/design_gate.pl`: pass - clean copied-pack install + load from outside the checkout: pass (new module travels with `cp -R prolog`) - `bin/prolog-rlm.pl -- demo --json`: pass; `git diff --check`: clean
A fresh SWI process whose user module defines text_string/2 (as
init-file-style hosts legitimately do) corrupts rlm_completion under the
canonical load order: rlm_completion triggers rlm_direct's load, whose
import of the not-yet-defined text_string/2 falls back to user and is
registered as an import of rlm_completion itself, rejecting the local
clauses and silently replacing completion_fault(expected_text/1) with
plain failure. The fixture also pins require_options/1 and zero_usage/1,
which are corruptible by the same class, across three load orders.
fix: break rlm_completion <-> rlm_direct import cycle (#328)
Some checks failed
Tree-sitter FFI / Direct SWI-Prolog Tree-sitter FFI (pull_request) Successful in 2m2s
CI / REAL OpenRouter integration (pull_request) Has been skipped
Nix flake / Flake package and clean runtime load (pull_request) Failing after 7s
Paid OpenRouter / Pinned paid OpenRouter integration (pull_request) Failing after 1m12s
Clean SWI pack install / Install and load copied pack (pull_request) Successful in 2m8s
CI / Deterministic unit and load checks (pull_request) Successful in 2m19s
e4113caf40
Move text_string/2 into a new rlm_text leaf module that both runtimes
import and rlm_completion re-exports, and load rlm_direct from the end
of rlm_completion so its helper imports never resolve against a
mid-load module. Resolving any import while rlm_completion is still
loading lets a same-named user predicate take ownership of the
procedure, reject the local definition, and drop structured fault
semantics for the whole imported helper surface.
nsaspy closed this pull request 2026-09-04 22:56:28 +00:00
Some checks failed
Tree-sitter FFI / Direct SWI-Prolog Tree-sitter FFI (pull_request) Successful in 2m2s
CI / REAL OpenRouter integration (pull_request) Has been skipped
Nix flake / Flake package and clean runtime load (pull_request) Failing after 7s
Paid OpenRouter / Pinned paid OpenRouter integration (pull_request) Failing after 1m12s
Clean SWI pack install / Install and load copied pack (pull_request) Successful in 2m8s
CI / Deterministic unit and load checks (pull_request) Successful in 2m19s

Pull request closed

Sign in to join this conversation.
No description provided.