P18 part A (order 20260914T105139Z). P16 measured S7a-3's ladder against a delivered corpus for the first time and found two things fixture bases cannot show. (1) One level is not bounded by being one level. Measured 14.09 on the four mounted vegnormal bases: okf.directory_listing on krav/N200 is 169 974 chars over 1 132 documents, krav/N100 69 250 over 445, krav/N500 39 853 over 269, and R761's own root 110 912 over 2 728 SUBDIRECTORIES -- 27-113x the 1 500-char ceiling S7a-3 set, riding in every later prompt. That last number is why the window covers BOTH kinds: a pagination over documents only would have left the largest measured level unpaginated. read_dir now answers with a window. offset/limit page directories first then documents as ONE sequence (two independent windows make "the next ten" a question with two answers); total is the denominator and is always carried; limit is CLAMPED to 50, never refused. Default 10 chosen against the ceiling: one entry is 121-209 chars (median 145) over the four bases. After: n100 1 493, n500 1 453, R761 479, n200 1 537 -- 2.5 % over, stated rather than tuned away, because the ceiling is a character budget and the window is a count. Largest single call any caller can make: ~7 600 chars. filter narrows a level instead of paging it: case-insensitive SUBSTRING over title + req_number/prosessnr and over a directory path, answering with total_matches beside total. A substring and not a pattern for _ground_against_input's reason one rung down -- a form the rule does not know returns nothing, and an empty listing reads as "the base does not have this". A filter that matches nothing is an ANSWER (total_matches: 0), never a refusal. ORDER PREMISE FELLED before building on it: the order asks for a separate top-level reader "like own_frontmatter" because parse_frontmatter was last-write-wins -- P15 (f13dc64) already made a top-level key win, so BundleFile.frontmatter IS the concept's own value and a second reader here would be the second copy ko-(p) forbids. (2) 0 of 26 fasit concepts were opened in 32 read_file calls (the order's "24" is the four runs' DISTINCT paths, re-measured 14.09), and 10 of those calls named a path the base does not hold. Each reached the model as MAF's opaque "Error: Function failed." while counting toward the three consecutive tool errors that end a request. read_file now refuses such a path by name (BundlePathNotFound, funn-99 returned form) and names the nearest directory that actually HOLDS documents -- chosen off context_files, never the filesystem, because a directory can exist on disk and hold no navigated concept (read_dir would then refuse the very path the refusal handed back) and because context_files is what drops the type: verdict layer, so a refusal can never advertise by name the one layer no listing mentions. Narrow by construction: only an ABSENT path is translated; any other OSError propagates untouched. Two existing arms REWRITTEN, neither weakened: - test_a_nonexistent_sibling_is_still_an_os_error was a tripwire whose own docstring said "when it goes red, someone has closed it, and that is a decision to be recorded". This is the record. Its narrowness half survives as a new arm driving a real PermissionError on a file that IS there. - test_every_document_is_still_reachable_and_the_counts_add_up became STRONGER: the accounting must now page, so the same assertion also proves the window is complete and non-overlapping. tests/test_navigation_window_loadbearing.py: 13 arms. Arms needing the delivered bases SKIP with the root named (PORTFOLIO_VEGNORMAL_ROOT), as MAJOR-3's ceiling arm does; the window algebra, the filter negative and the refusal run over a synthetic base UNCONDITIONALLY, so the file can never be silently absent in full. Verification: uv run pytest -q 1672 passed / 5 skipped before the new file (1670 oncfd9079). ruff check + format clean, mypy clean (38 files). Golden demo-transcript.stdout BYTE-UNCHANGED, shasum -a 1 of the CONTENT = ea8c534773acdbe41ae68f2c55724d69aaf8be4f. No version bump, no push. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
193 lines
10 KiB
Python
193 lines
10 KiB
Python
"""``read_file`` on a DIRECTORY is refused by name and pointed at ``read_dir``.
|
|
|
|
Finding (c) of the live K2 session (``docs/2026-09-06-major2-levende-k2.md`` § 3/§ 4): a live model
|
|
walking the ladder ``list_bundles -> read_bundle -> read_dir -> read_file`` reached a level holding
|
|
subdirectories (``30-1``, ``30-7``, ``521-001`` ...) and then called ``read_file`` on one of them.
|
|
Three such calls in one run.
|
|
|
|
**What was CHECKED FIRST, and what it found.** The order allows for two causes and asks which one
|
|
holds. It is the second. A listing DOES already separate the two kinds structurally: every
|
|
``directory_listing`` payload answers with ``directories`` (entries keyed ``path``, carrying a
|
|
subtree ``documents`` count) and ``documents`` (entries keyed ``name``, carrying ``type``, ``title``
|
|
and ``chars``) as two distinct keys, and no directory ever appears among the documents. Arm (4)
|
|
pins that, because it is a property this file now depends on rather than one it introduces. So no
|
|
trailing-``/`` marker is added: the shape already says which is which, and changing the payload
|
|
would move a listing three older gates measure byte for byte.
|
|
|
|
What was missing is the OTHER half. MEASURED before this work, on the shipped nested example base:
|
|
|
|
read_file(id, "a") -> builtins.IsADirectoryError: [Errno 21] Is a directory: '<abs path>'
|
|
|
|
An ``OSError``, so it lands on the crash channel rather than the CLI's refusal tuple and hosting's
|
|
400 arm, and it says nothing about which rung the caller should have used. The refusal now names
|
|
both -- that the path is a directory, and ``read_dir`` -- following ``VerdictLayerRefused`` and
|
|
``DimensionScopeRefused``: the rule lives in the TOOL, so it holds for both callers (the
|
|
exploration and, since S2c, the debate), and it is a ``ValueError`` because the caller is a model
|
|
choosing a path, not a program that is broken.
|
|
|
|
**MEASURED, REPORTED, NOT FIXED (outside this order).** The live call was
|
|
``read_file(".../30-7.md")`` -- the model appended ``.md`` to a directory NAME, which resolves to a
|
|
path that does not exist at all, not to the directory. Measured on the same base:
|
|
|
|
read_file(id, "a.md") -> builtins.FileNotFoundError: [Errno 2] No such file or directory
|
|
|
|
That is a second, adjacent defect with the same shape (an OSError on the crash channel where a
|
|
named refusal belongs), and the fix ordered here does not cover it. Arm (5) pins the measurement so
|
|
the gap is a fact in the suite rather than a sentence in a report.
|
|
|
|
Detach point: remove the directory branch from ``read_file`` -> RED on (1), (2) and (3).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from portfolio_optimiser import okf
|
|
from portfolio_optimiser.explore import DirectoryPathRefused, navigator_tools
|
|
|
|
_EXAMPLES = Path(__file__).resolve().parents[1] / "shared" / "examples"
|
|
#: The only NESTED base shipped -- the one that HAS a directory to ask for.
|
|
_NESTED = _EXAMPLES / "nav-golden-hierarchy" / "bundle"
|
|
|
|
|
|
def _tools(bundle_dir: Path) -> dict[str, Any]:
|
|
return {t.name: t for t in navigator_tools((str(bundle_dir),))}
|
|
|
|
|
|
async def _invoke(tool: Any, **arguments: Any) -> str:
|
|
"""A tool's answer as text (S2c's helper: ``invoke`` returns ``[Content]``, and a test that
|
|
stringified the list would compare object reprs and pass against anything)."""
|
|
return "".join(getattr(c, "text", "") or "" for c in await tool.invoke(arguments=arguments))
|
|
|
|
|
|
def _a_directory(bundle_dir: Path) -> str:
|
|
"""A directory path taken out of the listing itself -- the same way a model gets one."""
|
|
listing = _tools(bundle_dir)["read_bundle"].func(bundle_id=bundle_dir.name)
|
|
assert listing["directories"], "fixture has no subdirectory to ask for"
|
|
return str(listing["directories"][0]["path"])
|
|
|
|
|
|
def test_a_directory_path_is_refused_and_the_other_rung_is_named() -> None:
|
|
"""(1) the refusal says WHAT the path is and WHICH tool reads it."""
|
|
path = _a_directory(_NESTED)
|
|
message = _tools(_NESTED)["read_file"].func(bundle_id=_NESTED.name, path=path)
|
|
assert message.startswith(f"REFUSED ({DirectoryPathRefused.__name__})"), (
|
|
"the refusal is not distinguishable from a document's body"
|
|
)
|
|
assert path in message
|
|
assert "directory" in message
|
|
assert "read_dir" in message, "a refusal that does not name the other rung strands the caller"
|
|
|
|
|
|
def test_the_refusal_is_a_value_error_not_an_os_error() -> None:
|
|
"""(2) the channel, which is the half a message alone cannot carry.
|
|
|
|
``ValueError`` is the ``BundlePathNotFound``/``DimensionScopeRefused`` precedent -- the CLI's
|
|
refusal tuple and hosting's 400 arm. The ``OSError`` assert is what tells a REPLACEMENT from a
|
|
wrapper: re-raising ``IsADirectoryError`` with a better message would pass arm (1).
|
|
"""
|
|
path = _a_directory(_NESTED)
|
|
answer = _tools(_NESTED)["read_file"].func(bundle_id=_NESTED.name, path=path)
|
|
# F99-D3: the refusal is RETURNED now, so the branch is identified by the kind it names. The
|
|
# channel assertion moves onto the class itself and keeps both halves: it is on the CLI's
|
|
# refusal tuple, and it is NOT a re-raised ``IsADirectoryError`` wearing a better message --
|
|
# an ``OSError`` is outside ``_RETURNABLE_REFUSALS`` and would propagate rather than answer.
|
|
assert answer.startswith(f"REFUSED ({DirectoryPathRefused.__name__})")
|
|
assert issubclass(DirectoryPathRefused, ValueError)
|
|
assert not issubclass(DirectoryPathRefused, OSError)
|
|
|
|
|
|
async def test_the_refusal_holds_through_the_real_tool_surface() -> None:
|
|
"""(3) reached through ``invoke``, not only through ``.func``.
|
|
|
|
The seam a model actually calls is the tool, and a check that only ran on the plain function
|
|
would be absent from the one caller it was written for.
|
|
"""
|
|
path = _a_directory(_NESTED)
|
|
answer = await _invoke(_tools(_NESTED)["read_file"], bundle_id=_NESTED.name, path=path)
|
|
assert answer.startswith(f"REFUSED ({DirectoryPathRefused.__name__})")
|
|
assert "read_dir" in answer
|
|
|
|
|
|
def test_a_listing_already_separates_directories_from_documents() -> None:
|
|
"""(4) the "check first" arm: the payload's shape is what distinguishes the two kinds.
|
|
|
|
Also the anti-vacuity control for this file -- it proves the fixture really does hold both
|
|
kinds, so arm (1) is asking for a directory rather than for nothing.
|
|
"""
|
|
listing = _tools(_NESTED)["read_bundle"].func(bundle_id=_NESTED.name)
|
|
directories = {d["path"] for d in listing["directories"]}
|
|
documents = {d["name"] for d in listing["documents"]}
|
|
assert directories and documents, "fixture must hold both kinds for this to mean anything"
|
|
assert directories.isdisjoint(documents)
|
|
assert all(set(d) == {"path", "documents"} for d in listing["directories"])
|
|
assert all(set(d) == {"name", "type", "title", "chars"} for d in listing["documents"])
|
|
|
|
|
|
def test_a_document_is_still_returned_whole() -> None:
|
|
"""CONTROL: the branch is a gate, not a wall -- the rung it guards still works."""
|
|
listing = _tools(_NESTED)["read_bundle"].func(bundle_id=_NESTED.name)
|
|
name = listing["documents"][0]["name"]
|
|
body = _tools(_NESTED)["read_file"].func(bundle_id=_NESTED.name, path=name)
|
|
assert body.strip(), "reading a real document must be untouched by the directory branch"
|
|
|
|
|
|
def test_a_nonexistent_path_is_refused_by_name_and_the_nearest_directory_is_given() -> None:
|
|
"""(5) P18/A3 CLOSES the gap this arm used to record as open.
|
|
|
|
It used to assert ``pytest.raises(FileNotFoundError)`` and said in its own docstring: "when it
|
|
goes red, someone has closed it, and that is a decision to be recorded." This is the record.
|
|
|
|
MEASURED over P16's four paid runs before the change: 10 of 24 ``read_file`` calls named a path
|
|
the base does not hold (8 distinct paths, one of them a single-character UUID slip), and every
|
|
one of them reached the model as MAF's opaque ``"Error: Function failed."`` while counting
|
|
toward the three consecutive tool errors that end a request.
|
|
|
|
REWRITTEN, NEVER WEAKENED. The half this file exists for is still asserted below: only an
|
|
ABSENT path is translated, and the refusal is not an ``OSError`` wearing a better message.
|
|
"""
|
|
path = _a_directory(_NESTED) + ".md"
|
|
answer = _tools(_NESTED)["read_file"].func(bundle_id=_NESTED.name, path=path)
|
|
|
|
assert answer.startswith(f"REFUSED ({okf.BundlePathNotFound.__name__})")
|
|
assert path in answer, "a refusal that does not quote the path the caller sent is unactionable"
|
|
assert "read_dir" in answer, "the one thing the caller can act on is the rung that lists names"
|
|
assert issubclass(okf.BundlePathNotFound, ValueError)
|
|
assert not issubclass(okf.BundlePathNotFound, OSError)
|
|
|
|
|
|
def test_the_nearest_existing_directory_is_a_path_that_resolves() -> None:
|
|
"""(6) the anti-vacuity half of (5): a refusal that named a directory which does not exist
|
|
would be the ``_index_excerpt`` failure one rung up -- a path that never was is worse than no
|
|
path. The name the refusal hands back is fed straight into ``read_dir`` and must answer."""
|
|
deep = _a_directory(_NESTED) + "/undermappe-som-ikke-finnes/dokument.md"
|
|
answer = _tools(_NESTED)["read_file"].func(bundle_id=_NESTED.name, path=deep)
|
|
|
|
quoted = answer.rsplit("documents: ", 1)[1].split("'")[1]
|
|
listing = _tools(_NESTED)["read_dir"].func(bundle_id=_NESTED.name, path=quoted)
|
|
assert "refused" not in listing, f"the refusal named {quoted!r}, which does not resolve"
|
|
assert int(listing["total"]) > 0
|
|
|
|
|
|
def test_a_real_os_error_inside_the_base_is_not_masked() -> None:
|
|
"""(7) the NARROWNESS arm -- the half of the old tripwire that must survive the rewrite.
|
|
|
|
Only a path that is ABSENT becomes a refusal. A file that IS there and cannot be read is a
|
|
different fact: the caller's path was right and the machine failed, so translating it into
|
|
"this base has no such document" would tell the model a lie it would then act on. Driven with a
|
|
real, unreadable file rather than a mock, because the branch under test is ``Path.exists()``.
|
|
"""
|
|
document = next(
|
|
f for f in okf.navigate_bundle(str(_NESTED)).context_files if f.name.endswith(".md")
|
|
)
|
|
target = _NESTED / document.name
|
|
mode = target.stat().st_mode
|
|
target.chmod(0o000)
|
|
try:
|
|
with pytest.raises(PermissionError):
|
|
_tools(_NESTED)["read_file"].func(bundle_id=_NESTED.name, path=document.name)
|
|
finally:
|
|
target.chmod(mode)
|