diff --git a/src/basic_memory/indexing/wiki_projector.py b/src/basic_memory/indexing/wiki_projector.py new file mode 100644 index 000000000..aaaaeeed7 --- /dev/null +++ b/src/basic_memory/indexing/wiki_projector.py @@ -0,0 +1,632 @@ +"""Deterministic, storage-neutral planning for the Basic Memory Wiki Projector.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from enum import StrEnum +from hashlib import sha256 +import json +from pathlib import PurePosixPath + +OKF_VERSION = "0.2" +WIKI_PROFILE = "wiki/1" +WIKI_PROJECTOR_NAME = "Basic Memory Wiki Projector" +WIKI_PROJECTOR_SOURCE = "wiki_projector" +RESERVED_WIKI_FILENAMES = frozenset({"index.md", "log.md"}) + + +class WikiProjectionReason(StrEnum): + """Why a projector run was requested.""" + + accepted_note = "accepted_note" + project_created = "project_created" + import_rebuild = "import_rebuild" + manual_rebuild = "manual_rebuild" + + +class WikiChangeOperation(StrEnum): + """Accepted note operation represented in generated Wiki logs.""" + + created = "created" + updated = "updated" + moved = "moved" + deleted = "deleted" + + +class WikiProjectionState(StrEnum): + """User-visible state derived from a projector result or run ledger.""" + + current = "current" + updating = "updating" + partial = "partial" + conflicted = "conflicted" + failed = "failed" + + +@dataclass(frozen=True, slots=True) +class WikiProjectionRequest: + """Portable request consumed by local and Cloud projector adapters.""" + + project_id: str + through_partition_position: int + projector_version: str + reason: WikiProjectionReason + requested_scopes: tuple[str, ...] = () + + def __post_init__(self) -> None: + if not self.project_id.strip(): + raise ValueError("Wiki projection requires a project_id") + if self.through_partition_position < 0: + raise ValueError("Wiki projection position cannot be negative") + if not self.projector_version.strip(): + raise ValueError("Wiki projection requires a projector_version") + normalized_scopes = tuple( + sorted({_normalize_scope(scope) for scope in self.requested_scopes}) + ) + object.__setattr__(self, "requested_scopes", normalized_scopes) + + @property + def is_full_rebuild(self) -> bool: + return self.reason in { + WikiProjectionReason.import_rebuild, + WikiProjectionReason.manual_rebuild, + } + + +@dataclass(frozen=True, slots=True) +class WikiSourceNote: + """One accepted, materialized note visible to a projector snapshot.""" + + path: str + title: str + note_type: str + checksum: str + + def __post_init__(self) -> None: + object.__setattr__(self, "path", _normalize_note_path(self.path)) + if not self.title.strip(): + raise ValueError(f"Wiki source note {self.path} requires a title") + if not self.note_type.strip(): + raise ValueError(f"Wiki source note {self.path} requires a note_type") + if not self.checksum.strip(): + raise ValueError(f"Wiki source note {self.path} requires a checksum") + + +@dataclass(frozen=True, slots=True) +class WikiSourceChange: + """One accepted project-partition change used for materialization-aware logs.""" + + partition_position: int + operation: WikiChangeOperation + path: str + title: str + accepted_at: datetime + materialized: bool + source: str + previous_path: str | None = None + + def __post_init__(self) -> None: + if self.partition_position <= 0: + raise ValueError("Wiki source change position must be positive") + object.__setattr__(self, "path", _normalize_note_path(self.path)) + if self.previous_path is not None: + object.__setattr__(self, "previous_path", _normalize_note_path(self.previous_path)) + if not self.title.strip(): + raise ValueError(f"Wiki source change {self.path} requires a title") + if self.accepted_at.tzinfo is None: + raise ValueError("Wiki source change accepted_at must be timezone-aware") + if not self.source.strip(): + raise ValueError("Wiki source change requires a source") + + +@dataclass(frozen=True, slots=True) +class WikiReservedDocument: + """Current accepted state for a path reserved to the Wiki Projector.""" + + path: str + checksum: str + content: bytes + projector_owned: bool + + def __post_init__(self) -> None: + normalized_path = _normalize_note_path(self.path) + if PurePosixPath(normalized_path).name.lower() not in RESERVED_WIKI_FILENAMES: + raise ValueError(f"Wiki reserved document has non-reserved path: {self.path}") + if not self.checksum.strip(): + raise ValueError(f"Wiki reserved document {self.path} requires a checksum") + object.__setattr__(self, "path", normalized_path) + + +@dataclass(frozen=True, slots=True) +class WikiProjectionSnapshot: + """Complete deterministic input needed to plan one projector run.""" + + project_id: str + project_name: str + current_output_watermark: int + source_accepted_at: datetime + notes: tuple[WikiSourceNote, ...] + changes: tuple[WikiSourceChange, ...] + reserved_documents: tuple[WikiReservedDocument, ...] = () + + def __post_init__(self) -> None: + if not self.project_id.strip(): + raise ValueError("Wiki projection snapshot requires a project_id") + if not self.project_name.strip(): + raise ValueError("Wiki projection snapshot requires a project_name") + if self.current_output_watermark < 0: + raise ValueError("Wiki output watermark cannot be negative") + if self.source_accepted_at.tzinfo is None: + raise ValueError("Wiki snapshot source_accepted_at must be timezone-aware") + _require_unique_paths(self.notes, label="source note") + _require_unique_paths(self.reserved_documents, label="reserved document") + positions = [change.partition_position for change in self.changes] + if len(positions) != len(set(positions)): + raise ValueError("Wiki source changes require unique partition positions") + + +@dataclass(frozen=True, slots=True) +class WikiProjectionWrite: + """Checksum-protected canonical Markdown write planned for an adapter.""" + + path: str + content: bytes + checksum: str + expected_checksum: str | None + + +@dataclass(frozen=True, slots=True) +class WikiProjectionConflict: + """Reserved path the projector cannot safely claim or replace.""" + + path: str + reason: str + + +@dataclass(frozen=True, slots=True) +class WikiProjectionResult: + """Portable outcome recorded by local and Cloud run ledgers.""" + + source_watermark: int + output_watermark: int + created: int + updated: int + unchanged: int + conflicts: tuple[WikiProjectionConflict, ...] + warnings: tuple[str, ...] + pending_materialization: tuple[int, ...] + + @property + def state(self) -> WikiProjectionState: + if self.conflicts: + return WikiProjectionState.conflicted + if self.pending_materialization: + return WikiProjectionState.partial + if self.output_watermark < self.source_watermark: + return WikiProjectionState.updating + return WikiProjectionState.current + + +@dataclass(frozen=True, slots=True) +class WikiProjectionPlan: + """Pure projection result plus writes for a runtime adapter to execute.""" + + request: WikiProjectionRequest + writes: tuple[WikiProjectionWrite, ...] + unchanged_paths: tuple[str, ...] + result: WikiProjectionResult + + +def affected_wiki_scopes(*paths: str | None) -> tuple[str, ...]: + """Return root and every ancestor directory affected by note paths.""" + scopes = {""} + for path in paths: + if path is None: + continue + parent = PurePosixPath(_normalize_note_path(path)).parent + while parent != PurePosixPath("."): + scopes.add(parent.as_posix()) + parent = parent.parent + return tuple(sorted(scopes)) + + +def plan_wiki_projection( + request: WikiProjectionRequest, + snapshot: WikiProjectionSnapshot, +) -> WikiProjectionPlan: + """Plan deterministic OKF index/log writes without performing I/O.""" + if request.project_id != snapshot.project_id: + raise ValueError("Wiki projection request and snapshot project_id differ") + if request.through_partition_position < snapshot.current_output_watermark: + raise ValueError("Wiki projection request is older than the current output watermark") + + changes = tuple( + sorted( + ( + change + for change in snapshot.changes + if change.partition_position <= request.through_partition_position + and not _is_projector_change(change) + ), + key=lambda change: change.partition_position, + ) + ) + pending = tuple( + change.partition_position + for change in changes + if not change.materialized and change.partition_position > snapshot.current_output_watermark + ) + if pending: + warning = ( + "Projection deferred until accepted note positions are materialized: " + + ", ".join(str(position) for position in pending) + ) + return WikiProjectionPlan( + request=request, + writes=(), + unchanged_paths=(), + result=WikiProjectionResult( + source_watermark=request.through_partition_position, + output_watermark=snapshot.current_output_watermark, + created=0, + updated=0, + unchanged=0, + conflicts=(), + warnings=(warning,), + pending_materialization=pending, + ), + ) + + # A projector-only replay advances the ledger without rewriting its own + # generated notes. Full rebuilds and project creation remain explicit work. + new_changes = tuple( + change + for change in changes + if change.partition_position > snapshot.current_output_watermark + ) + if ( + request.reason == WikiProjectionReason.accepted_note + and not new_changes + and snapshot.reserved_documents + ): + return WikiProjectionPlan( + request=request, + writes=(), + unchanged_paths=tuple( + sorted(document.path for document in snapshot.reserved_documents) + ), + result=WikiProjectionResult( + source_watermark=request.through_partition_position, + output_watermark=request.through_partition_position, + created=0, + updated=0, + unchanged=len(snapshot.reserved_documents), + conflicts=(), + warnings=(), + pending_materialization=(), + ), + ) + + scopes = _projection_scopes(request, snapshot, new_changes) + existing_by_path = {document.path: document for document in snapshot.reserved_documents} + notes = tuple( + note + for note in snapshot.notes + if PurePosixPath(note.path).name.lower() not in RESERVED_WIKI_FILENAMES + ) + rendered: dict[str, bytes] = {} + for scope in scopes: + rendered[_reserved_path(scope, "index.md")] = _render_index( + snapshot=snapshot, + notes=notes, + scope=scope, + source_watermark=request.through_partition_position, + ) + rendered[_reserved_path(scope, "log.md")] = _render_log( + snapshot=snapshot, + changes=changes, + scope=scope, + source_watermark=request.through_partition_position, + ) + + conflicts = tuple( + WikiProjectionConflict( + path=path, + reason="reserved path is not owned by the Wiki Projector", + ) + for path in sorted(rendered) + if (existing := existing_by_path.get(path)) is not None and not existing.projector_owned + ) + if conflicts: + # Indexes and logs describe one project watermark. Writing only the + # unblocked paths would publish a mixed projection that no ledger + # watermark could honestly represent, so conflict is all-or-nothing. + return WikiProjectionPlan( + request=request, + writes=(), + unchanged_paths=(), + result=WikiProjectionResult( + source_watermark=request.through_partition_position, + output_watermark=snapshot.current_output_watermark, + created=0, + updated=0, + unchanged=0, + conflicts=conflicts, + warnings=(), + pending_materialization=(), + ), + ) + + writes: list[WikiProjectionWrite] = [] + unchanged_paths: list[str] = [] + created = 0 + updated = 0 + for path, content in sorted(rendered.items()): + existing = existing_by_path.get(path) + if existing is not None and existing.content == content: + unchanged_paths.append(path) + continue + writes.append( + WikiProjectionWrite( + path=path, + content=content, + checksum=sha256(content).hexdigest(), + expected_checksum=existing.checksum if existing is not None else None, + ) + ) + if existing is None: + created += 1 + else: + updated += 1 + + return WikiProjectionPlan( + request=request, + writes=tuple(writes), + unchanged_paths=tuple(unchanged_paths), + result=WikiProjectionResult( + source_watermark=request.through_partition_position, + output_watermark=request.through_partition_position, + created=created, + updated=updated, + unchanged=len(unchanged_paths), + conflicts=(), + warnings=(), + pending_materialization=(), + ), + ) + + +def _projection_scopes( + request: WikiProjectionRequest, + snapshot: WikiProjectionSnapshot, + new_changes: tuple[WikiSourceChange, ...], +) -> tuple[str, ...]: + if request.is_full_rebuild: + paths = [note.path for note in snapshot.notes] + return affected_wiki_scopes(*paths) + if request.requested_scopes: + scopes = {""} + for requested_scope in request.requested_scopes: + scope = PurePosixPath(requested_scope) + while scope != PurePosixPath("."): + scopes.add(scope.as_posix()) + scope = scope.parent + return tuple(sorted(scopes)) + paths = [change.path for change in new_changes] + paths.extend(change.previous_path for change in new_changes if change.previous_path is not None) + return affected_wiki_scopes(*paths) + + +def _render_index( + *, + snapshot: WikiProjectionSnapshot, + notes: tuple[WikiSourceNote, ...], + scope: str, + source_watermark: int, +) -> bytes: + direct_notes = sorted( + (note for note in notes if _parent_scope(note.path) == scope), + key=lambda note: (note.title.casefold(), note.path.casefold()), + ) + child_scope_set: set[str] = set() + for note in notes: + if not _is_descendant(note.path, scope): + continue + child_scope = _direct_child_scope(scope, note.path) + if child_scope is not None: + child_scope_set.add(child_scope) + child_scopes = sorted(child_scope_set) + title = snapshot.project_name if not scope else _display_name(PurePosixPath(scope).name) + body: list[str] = [f"# {title}", ""] + if child_scopes: + body.extend(["## Sections", ""]) + body.extend( + f"- [[{child_scope}/index|{_display_name(PurePosixPath(child_scope).name)}]]" + for child_scope in child_scopes + ) + body.append("") + if direct_notes: + body.extend(["## Notes", ""]) + body.extend( + f"- [[{_without_markdown_suffix(note.path)}|{note.title}]]" for note in direct_notes + ) + body.append("") + if not child_scopes and not direct_notes: + body.extend(["No concepts have been projected into this scope yet.", ""]) + return _render_document( + note_type="Index", + title=title, + source_watermark=source_watermark, + generated_at=snapshot.source_accepted_at, + body="\n".join(body), + include_okf_version=not scope, + ) + + +def _render_log( + *, + snapshot: WikiProjectionSnapshot, + changes: tuple[WikiSourceChange, ...], + scope: str, + source_watermark: int, +) -> bytes: + relevant = tuple( + sorted( + ( + change + for change in changes + if change.materialized + and ( + _is_descendant(change.path, scope) + or ( + change.previous_path is not None + and _is_descendant(change.previous_path, scope) + ) + ) + ), + key=lambda change: change.partition_position, + reverse=True, + ) + ) + title = ( + f"{snapshot.project_name} log" + if not scope + else f"{_display_name(PurePosixPath(scope).name)} log" + ) + body: list[str] = [f"# {title}", ""] + if relevant: + body.extend(_render_log_entry(change) for change in relevant) + body.append("") + else: + body.extend(["No accepted materialized changes have been recorded yet.", ""]) + return _render_document( + note_type="Log", + title=title, + source_watermark=source_watermark, + generated_at=snapshot.source_accepted_at, + body="\n".join(body), + include_okf_version=False, + ) + + +def _render_log_entry(change: WikiSourceChange) -> str: + timestamp = _isoformat_utc(change.accepted_at) + match change.operation: + case WikiChangeOperation.created: + description = f"Created [[{_without_markdown_suffix(change.path)}|{change.title}]]" + case WikiChangeOperation.updated: + description = f"Updated [[{_without_markdown_suffix(change.path)}|{change.title}]]" + case WikiChangeOperation.moved: + if change.previous_path is None: + raise ValueError("Moved Wiki change requires previous_path") + description = ( + f"Moved `{change.previous_path}` to " + f"[[{_without_markdown_suffix(change.path)}|{change.title}]]" + ) + case WikiChangeOperation.deleted: + description = f"Deleted `{change.path}`" + return f"- {timestamp} — {description}" + + +def _render_document( + *, + note_type: str, + title: str, + source_watermark: int, + generated_at: datetime, + body: str, + include_okf_version: bool, +) -> bytes: + frontmatter = ["---", f"type: {note_type}"] + if include_okf_version: + frontmatter.append(f'okf_version: "{OKF_VERSION}"') + frontmatter.extend( + [ + f"title: {json.dumps(title, ensure_ascii=False)}", + "generated:", + f" by: {WIKI_PROJECTOR_NAME}", + f" at: {json.dumps(_isoformat_utc(generated_at))}", + "bm:", + f" profile: {WIKI_PROFILE}", + f' source_watermark: "{source_watermark}"', + "---", + body, + ] + ) + return ("\n".join(frontmatter).rstrip() + "\n").encode("utf-8") + + +def _is_projector_change(change: WikiSourceChange) -> bool: + return ( + change.source == WIKI_PROJECTOR_SOURCE + and PurePosixPath(change.path).name.lower() in RESERVED_WIKI_FILENAMES + ) + + +def _normalize_note_path(path: str) -> str: + normalized = _normalize_relative_path(path) + if not normalized or PurePosixPath(normalized).suffix.lower() != ".md": + raise ValueError(f"Wiki note path must be project-relative Markdown: {path}") + return normalized + + +def _normalize_scope(scope: str) -> str: + return _normalize_relative_path(scope) + + +def _normalize_relative_path(path: str) -> str: + candidate = path.strip().replace("\\", "/") + if candidate.startswith("/"): + raise ValueError(f"Wiki path must be project-relative and normalized: {path}") + candidate = candidate.strip("/") + if not candidate: + return "" + parsed = PurePosixPath(candidate) + if parsed.is_absolute() or any(part in {"", ".", ".."} for part in parsed.parts): + raise ValueError(f"Wiki path must be project-relative and normalized: {path}") + return parsed.as_posix() + + +def _require_unique_paths(values: tuple[object, ...], *, label: str) -> None: + paths = [getattr(value, "path") for value in values] + if len(paths) != len(set(paths)): + raise ValueError(f"Wiki projection snapshot has duplicate {label} paths") + + +def _reserved_path(scope: str, filename: str) -> str: + return f"{scope}/{filename}" if scope else filename + + +def _parent_scope(path: str) -> str: + parent = PurePosixPath(path).parent + return "" if parent == PurePosixPath(".") else parent.as_posix() + + +def _is_descendant(path: str, scope: str) -> bool: + if not scope: + return True + return path == scope or path.startswith(f"{scope}/") + + +def _direct_child_scope(scope: str, note_path: str) -> str | None: + note_parent = _parent_scope(note_path) + if not note_parent or note_parent == scope: + return None + prefix = f"{scope}/" if scope else "" + if not note_parent.startswith(prefix): + return None + child_name = note_parent[len(prefix) :].split("/", maxsplit=1)[0] + return f"{scope}/{child_name}" if scope else child_name + + +def _display_name(value: str) -> str: + return value.replace("-", " ").replace("_", " ").strip().title() + + +def _without_markdown_suffix(path: str) -> str: + return path[:-3] if path.lower().endswith(".md") else path + + +def _isoformat_utc(value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/tests/fixtures/wiki_projector/basic_projection.json b/tests/fixtures/wiki_projector/basic_projection.json new file mode 100644 index 000000000..0ef9090d1 --- /dev/null +++ b/tests/fixtures/wiki_projector/basic_projection.json @@ -0,0 +1,11 @@ +{ + "contract_version": "wiki/1.0.0", + "project_id": "project-88", + "through_partition_position": 3, + "expected_sha256": { + "guides/index.md": "c6481bd17c663a3c2595cb35cd22a03da46c6d55465a274482a91363b3af244a", + "guides/log.md": "a3bf317e661d40a156a7481478938b8a652d4a69184b06cfa8c1b8b5355307fb", + "index.md": "33a4af87c4c1a3d4e128f780e67a8a7084aa1796118fb7a5334bbec4a4e0728d", + "log.md": "8e21ce176e930556f6a39f30412af7c488f9724b4118d2946879b72d0eb6c2f3" + } +} diff --git a/tests/indexing/test_wiki_projector.py b/tests/indexing/test_wiki_projector.py new file mode 100644 index 000000000..5beae52ae --- /dev/null +++ b/tests/indexing/test_wiki_projector.py @@ -0,0 +1,258 @@ +"""Deterministic Wiki Projector contract and byte-output tests.""" + +from datetime import datetime, timezone +from hashlib import sha256 +import json +from pathlib import Path + +import pytest + +from basic_memory.indexing.wiki_projector import ( + WikiChangeOperation, + WikiProjectionReason, + WikiProjectionRequest, + WikiProjectionSnapshot, + WikiProjectionState, + WikiReservedDocument, + WikiSourceChange, + WikiSourceNote, + affected_wiki_scopes, + plan_wiki_projection, +) + +ACCEPTED_AT = datetime(2026, 8, 29, 18, 30, tzinfo=timezone.utc) + + +def _request( + *, + position: int = 3, + reason: WikiProjectionReason = WikiProjectionReason.accepted_note, + scopes: tuple[str, ...] = ("guides",), +) -> WikiProjectionRequest: + return WikiProjectionRequest( + project_id="project-88", + through_partition_position=position, + projector_version="wiki/1.0.0", + reason=reason, + requested_scopes=scopes, + ) + + +def _snapshot( + *, + output_watermark: int = 2, + materialized: bool = True, + reserved_documents: tuple[WikiReservedDocument, ...] = (), +) -> WikiProjectionSnapshot: + return WikiProjectionSnapshot( + project_id="project-88", + project_name="Project 88", + current_output_watermark=output_watermark, + source_accepted_at=ACCEPTED_AT, + notes=( + WikiSourceNote( + path="overview.md", + title="Overview", + note_type="Note", + checksum="overview-checksum", + ), + WikiSourceNote( + path="guides/setup.md", + title="Setup", + note_type="Guide", + checksum="setup-checksum", + ), + WikiSourceNote( + path="guides/deep/details.md", + title="Details", + note_type="Guide", + checksum="details-checksum", + ), + ), + changes=( + WikiSourceChange( + partition_position=3, + operation=WikiChangeOperation.updated, + path="guides/setup.md", + title="Setup", + accepted_at=ACCEPTED_AT, + materialized=materialized, + source="web", + ), + ), + reserved_documents=reserved_documents, + ) + + +def _reserved(path: str, content: bytes, *, owned: bool = True) -> WikiReservedDocument: + return WikiReservedDocument( + path=path, + checksum=sha256(content).hexdigest(), + content=content, + projector_owned=owned, + ) + + +def test_affected_scopes_include_root_and_move_ancestors() -> None: + assert affected_wiki_scopes("guides/old/setup.md", "reference/new/setup.md") == ( + "", + "guides", + "guides/old", + "reference", + "reference/new", + ) + + +def test_projection_renders_root_and_affected_directory_indexes_and_logs() -> None: + plan = plan_wiki_projection(_request(), _snapshot()) + + assert [write.path for write in plan.writes] == [ + "guides/index.md", + "guides/log.md", + "index.md", + "log.md", + ] + rendered = {write.path: write.content.decode() for write in plan.writes} + assert "[[guides/deep/index|Deep]]" in rendered["guides/index.md"] + assert "[[guides/setup|Setup]]" in rendered["guides/index.md"] + assert "[[guides/index|Guides]]" in rendered["index.md"] + assert "[[overview|Overview]]" in rendered["index.md"] + assert "Updated [[guides/setup|Setup]]" in rendered["guides/log.md"] + assert plan.result.source_watermark == 3 + assert plan.result.output_watermark == 3 + assert plan.result.created == 4 + assert plan.result.state == WikiProjectionState.current + + +def test_projection_bytes_match_the_shared_contract_fixture() -> None: + fixture_path = ( + Path(__file__).parents[1] / "fixtures" / "wiki_projector" / "basic_projection.json" + ) + fixture = json.loads(fixture_path.read_text()) + + plan = plan_wiki_projection(_request(), _snapshot()) + + assert fixture["contract_version"] == plan.request.projector_version + assert fixture["project_id"] == plan.request.project_id + assert fixture["through_partition_position"] == plan.request.through_partition_position + assert fixture["expected_sha256"] == {write.path: write.checksum for write in plan.writes} + + +def test_projection_is_a_byte_identical_noop_at_the_same_watermark() -> None: + first = plan_wiki_projection(_request(), _snapshot()) + existing = tuple(_reserved(write.path, write.content) for write in first.writes) + + replay = plan_wiki_projection( + _request(), + _snapshot(output_watermark=3, reserved_documents=existing), + ) + + assert replay.writes == () + assert replay.unchanged_paths == tuple(write.path for write in first.writes) + assert replay.result.unchanged == 4 + assert replay.result.state == WikiProjectionState.current + + +def test_pending_materialization_defers_all_bytes_without_advancing_output() -> None: + plan = plan_wiki_projection(_request(), _snapshot(materialized=False)) + + assert plan.writes == () + assert plan.result.output_watermark == 2 + assert plan.result.pending_materialization == (3,) + assert plan.result.state == WikiProjectionState.partial + + +def test_user_claimed_reserved_path_is_a_conflict_not_a_write() -> None: + claimed = _reserved("guides/index.md", b"# User index\n", owned=False) + + plan = plan_wiki_projection( + _request(), + _snapshot(reserved_documents=(claimed,)), + ) + + assert plan.writes == () + assert plan.result.conflicts[0].path == "guides/index.md" + assert plan.result.output_watermark == 2 + assert plan.result.state == WikiProjectionState.conflicted + + +def test_projector_generated_change_is_suppressed_from_writes_and_log() -> None: + existing = _reserved("index.md", b"existing\n") + snapshot = WikiProjectionSnapshot( + project_id="project-88", + project_name="Project 88", + current_output_watermark=3, + source_accepted_at=ACCEPTED_AT, + notes=(), + changes=( + WikiSourceChange( + partition_position=4, + operation=WikiChangeOperation.updated, + path="index.md", + title="Project 88", + accepted_at=ACCEPTED_AT, + materialized=True, + source="wiki_projector", + ), + ), + reserved_documents=(existing,), + ) + + plan = plan_wiki_projection(_request(position=4, scopes=()), snapshot) + + assert plan.writes == () + assert plan.result.output_watermark == 4 + assert plan.result.state == WikiProjectionState.current + + +def test_full_rebuild_covers_every_note_directory() -> None: + request = _request( + reason=WikiProjectionReason.import_rebuild, + scopes=(), + ) + + plan = plan_wiki_projection(request, _snapshot()) + + assert {write.path for write in plan.writes} == { + "index.md", + "log.md", + "guides/index.md", + "guides/log.md", + "guides/deep/index.md", + "guides/deep/log.md", + } + + +def test_moved_change_requires_previous_path() -> None: + with pytest.raises(ValueError, match="requires previous_path"): + plan_wiki_projection( + _request(), + WikiProjectionSnapshot( + project_id="project-88", + project_name="Project 88", + current_output_watermark=2, + source_accepted_at=ACCEPTED_AT, + notes=(), + changes=( + WikiSourceChange( + partition_position=3, + operation=WikiChangeOperation.moved, + path="guides/new.md", + title="Moved", + accepted_at=ACCEPTED_AT, + materialized=True, + source="web", + ), + ), + ), + ) + + +def test_absolute_paths_are_rejected_at_the_contract_boundary() -> None: + with pytest.raises(ValueError, match="project-relative"): + WikiSourceNote( + path="/outside.md", + title="Outside", + note_type="Note", + checksum="checksum", + )