"""Tests for muse/plugins/knowtation/detector.py — Phase 1.2. Test tiers covered (per Rule #0 and plan §0.2): - **Unit** — 12 per-rule fixture cases covering every branch of the priority cascade (file-level and repo-level), plus edge cases (empty content, binary-like, unknown keys, etc.). - **Data-integrity** — real Knowtation vault notes at /Users/aaronrenecarvajal/knowtation/vault/ are classified; ≥99% must resolve to "knowtation". Skipped when the vault is absent (CI / fresh checkout). Tiers deferred to later phases: - Integration — ``muse init --domain auto`` selects knowtation (Phase 1.5+). - Security — adversarial frontmatter (YAML injection, oversized blocks) tested in Phase 2.2. Rule map for the 12 unit cases ------------------------------- File-level (classify_note): F1 source key present → knowtation (rule: source_key) F2 source_type key present → knowtation (rule: source_key) F3 source_id key alone → knowtation (rule: source_id_key) F4 project + date → knowtation (rule: project_or_tags_plus_date) F5 tags + date → knowtation (rule: project_or_tags_plus_date) F6 project alone (no date) → mist (rule: fallback_mist) F7 date alone (no project/tags) → mist (rule: fallback_mist) F8 empty content → mist (rule: no_frontmatter) F9 no frontmatter block → mist (rule: no_frontmatter) F10 frontmatter with only unknown keys → mist (rule: fallback_mist) Repo-level (classify_repo): R11 .knowtation marker file present → knowtation (rule: marker_file) R12 no .md files at all → mist (rule: no_markdown_notes) """ from __future__ import annotations import pathlib import pytest from muse.plugins.knowtation.detector import ( ClassificationResult, DOMAIN_KNOWTATION, DOMAIN_MIST, MARKER_FILENAME, REPO_MAJORITY_THRESHOLD, classify_many, classify_note, classify_repo, extract_frontmatter_keys, knowtation_fraction, ) # --------------------------------------------------------------------------- # Fixtures — byte strings # --------------------------------------------------------------------------- _SOURCE_NOTE = b"""\ --- source: telegram date: 2026-05-12 --- # Message from Telegram """ _SOURCE_TYPE_NOTE = b"""\ --- source_type: pdf date: 2026-04-01 project: ai-safety --- # PDF Import """ _SOURCE_ID_NOTE = b"""\ --- source_id: msg-abc-123 title: Some note --- # Externally tracked note """ _PROJECT_DATE_NOTE = b"""\ --- title: My Note project: born-free date: 2026-01-15 --- # Born Free Note """ _TAGS_DATE_NOTE = b"""\ --- tags: [research, alignment] date: 2026-03-01 --- # Tagged Note """ _PROJECT_ONLY_NOTE = b"""\ --- project: ai-safety title: No date here --- # Project note with no date """ _DATE_ONLY_NOTE = b"""\ --- date: 2026-05-01 title: Just a date --- # Date only """ _EMPTY_NOTE = b"" _NO_FRONTMATTER_NOTE = b"""\ # Just Markdown No frontmatter at all. """ _UNKNOWN_KEYS_NOTE = b"""\ --- foo: bar baz: qux --- # Only unknown keys """ # =========================================================================== # extract_frontmatter_keys — unit tests # =========================================================================== class TestExtractFrontmatterKeys: """extract_frontmatter_keys must return only top-level key names.""" def test_source_note_keys(self) -> None: keys = extract_frontmatter_keys(_SOURCE_NOTE) assert "source" in keys assert "date" in keys def test_no_frontmatter_returns_empty(self) -> None: assert extract_frontmatter_keys(_NO_FRONTMATTER_NOTE) == frozenset() def test_empty_content_returns_empty(self) -> None: assert extract_frontmatter_keys(_EMPTY_NOTE) == frozenset() def test_yaml_list_tag_key_detected(self) -> None: note = b"---\ntags: [a, b, c]\ndate: 2026-01-01\n---\n# Title" keys = extract_frontmatter_keys(note) assert "tags" in keys assert "date" in keys def test_comma_separated_tags_key_detected(self) -> None: note = b"---\ntags: a, b, c\ndate: 2026-01-01\n---\n# Title" keys = extract_frontmatter_keys(note) assert "tags" in keys def test_nested_key_not_counted(self) -> None: note = b"---\nparent:\n child: value\ntitle: Test\n---\n# T" keys = extract_frontmatter_keys(note) assert "parent" in keys assert "title" in keys # "child" is indented — must NOT appear as a top-level key assert "child" not in keys def test_unknown_keys_only(self) -> None: keys = extract_frontmatter_keys(_UNKNOWN_KEYS_NOTE) assert keys == frozenset({"foo", "baz"}) def test_unclosed_frontmatter_returns_empty(self) -> None: note = b"---\ntitle: No closing delimiter" assert extract_frontmatter_keys(note) == frozenset() def test_yaml_dot_dot_dot_delimiter(self) -> None: note = b"---\ntitle: Using dots\ndate: 2026-01-01\n...\n# Body" keys = extract_frontmatter_keys(note) assert "title" in keys assert "date" in keys def test_stable_for_same_input(self) -> None: k1 = extract_frontmatter_keys(_PROJECT_DATE_NOTE) k2 = extract_frontmatter_keys(_PROJECT_DATE_NOTE) assert k1 == k2 # =========================================================================== # classify_note — 12 per-rule unit cases (F1–F10) # =========================================================================== class TestClassifyNoteRule1_SourceKey: """F1 — 'source' key → knowtation.""" def test_domain_is_knowtation(self) -> None: assert classify_note(_SOURCE_NOTE).domain == DOMAIN_KNOWTATION def test_rule_is_source_key(self) -> None: assert classify_note(_SOURCE_NOTE).rule == "source_key" def test_is_knowtation_property(self) -> None: assert classify_note(_SOURCE_NOTE).is_knowtation class TestClassifyNoteRule1_SourceTypeKey: """F2 — 'source_type' key → knowtation.""" def test_domain_is_knowtation(self) -> None: assert classify_note(_SOURCE_TYPE_NOTE).domain == DOMAIN_KNOWTATION def test_rule_is_source_key(self) -> None: assert classify_note(_SOURCE_TYPE_NOTE).rule == "source_key" class TestClassifyNoteRule2_SourceIdKey: """F3 — 'source_id' key (alone, no source/source_type) → knowtation.""" def test_domain_is_knowtation(self) -> None: assert classify_note(_SOURCE_ID_NOTE).domain == DOMAIN_KNOWTATION def test_rule_is_source_id_key(self) -> None: assert classify_note(_SOURCE_ID_NOTE).rule == "source_id_key" def test_source_id_without_source_is_sufficient(self) -> None: note = b"---\nsource_id: ticket-42\ntitle: Ticket\n---\n# Body" result = classify_note(note) assert result.domain == DOMAIN_KNOWTATION assert result.rule == "source_id_key" class TestClassifyNoteRule3_ProjectDate: """F4 — project + date → knowtation.""" def test_domain_is_knowtation(self) -> None: assert classify_note(_PROJECT_DATE_NOTE).domain == DOMAIN_KNOWTATION def test_rule_is_project_or_tags_plus_date(self) -> None: assert classify_note(_PROJECT_DATE_NOTE).rule == "project_or_tags_plus_date" class TestClassifyNoteRule3_TagsDate: """F5 — tags + date → knowtation.""" def test_domain_is_knowtation(self) -> None: assert classify_note(_TAGS_DATE_NOTE).domain == DOMAIN_KNOWTATION def test_rule_is_project_or_tags_plus_date(self) -> None: assert classify_note(_TAGS_DATE_NOTE).rule == "project_or_tags_plus_date" class TestClassifyNoteRule4_ProjectOnly: """F6 — project without date → mist (rule 3 requires date).""" def test_domain_is_mist(self) -> None: assert classify_note(_PROJECT_ONLY_NOTE).domain == DOMAIN_MIST def test_is_not_knowtation(self) -> None: assert not classify_note(_PROJECT_ONLY_NOTE).is_knowtation class TestClassifyNoteRule4_DateOnly: """F7 — date without project/tags → mist.""" def test_domain_is_mist(self) -> None: assert classify_note(_DATE_ONLY_NOTE).domain == DOMAIN_MIST def test_is_not_knowtation(self) -> None: assert not classify_note(_DATE_ONLY_NOTE).is_knowtation class TestClassifyNoteEmptyContent: """F8 — empty content → mist (no_frontmatter).""" def test_domain_is_mist(self) -> None: assert classify_note(_EMPTY_NOTE).domain == DOMAIN_MIST def test_rule_is_no_frontmatter(self) -> None: assert classify_note(_EMPTY_NOTE).rule == "no_frontmatter" def test_confidence_is_zero(self) -> None: assert classify_note(_EMPTY_NOTE).confidence == 0.0 class TestClassifyNoteNoFrontmatter: """F9 — plain Markdown without frontmatter → mist (no_frontmatter).""" def test_domain_is_mist(self) -> None: assert classify_note(_NO_FRONTMATTER_NOTE).domain == DOMAIN_MIST def test_rule_is_no_frontmatter(self) -> None: assert classify_note(_NO_FRONTMATTER_NOTE).rule == "no_frontmatter" class TestClassifyNoteUnknownKeys: """F10 — frontmatter with no knowtation-identifying keys → mist.""" def test_domain_is_mist(self) -> None: assert classify_note(_UNKNOWN_KEYS_NOTE).domain == DOMAIN_MIST def test_rule_is_fallback_mist(self) -> None: assert classify_note(_UNKNOWN_KEYS_NOTE).rule == "fallback_mist" # =========================================================================== # classify_repo — repo-level rules (R11–R12) # =========================================================================== class TestClassifyRepoMarkerFile: """R11 — .knowtation marker file → knowtation.""" def test_marker_classifies_repo_as_knowtation(self, tmp_path: pathlib.Path) -> None: (tmp_path / MARKER_FILENAME).write_text("") result = classify_repo(tmp_path) assert result.domain == DOMAIN_KNOWTATION def test_rule_is_marker_file(self, tmp_path: pathlib.Path) -> None: (tmp_path / MARKER_FILENAME).write_text("") result = classify_repo(tmp_path) assert result.rule == "marker_file" def test_marker_overrides_absence_of_notes(self, tmp_path: pathlib.Path) -> None: (tmp_path / MARKER_FILENAME).write_text("") # No .md files at all — marker still wins result = classify_repo(tmp_path) assert result.domain == DOMAIN_KNOWTATION class TestClassifyRepoNoMarkdown: """R12 — no .md files → mist (no_markdown_notes).""" def test_empty_repo_is_mist(self, tmp_path: pathlib.Path) -> None: result = classify_repo(tmp_path) assert result.domain == DOMAIN_MIST def test_rule_is_no_markdown_notes(self, tmp_path: pathlib.Path) -> None: result = classify_repo(tmp_path) assert result.rule == "no_markdown_notes" def test_non_markdown_files_ignored(self, tmp_path: pathlib.Path) -> None: (tmp_path / "image.png").write_bytes(b"\x89PNG") (tmp_path / "data.json").write_text('{"key": "value"}') result = classify_repo(tmp_path) assert result.domain == DOMAIN_MIST assert result.rule == "no_markdown_notes" class TestClassifyRepoMajorityVote: """Majority-vote rule: ≥ REPO_MAJORITY_THRESHOLD fraction → knowtation.""" def _write_note( self, root: pathlib.Path, name: str, content: bytes ) -> None: root.mkdir(parents=True, exist_ok=True) (root / name).write_bytes(content) def test_all_knowtation_notes(self, tmp_path: pathlib.Path) -> None: for i in range(5): (tmp_path / f"note{i}.md").write_bytes(_SOURCE_NOTE) result = classify_repo(tmp_path) assert result.domain == DOMAIN_KNOWTATION assert result.rule == "majority_vote" assert result.confidence == 1.0 def test_majority_threshold_met(self, tmp_path: pathlib.Path) -> None: # 3 knowtation, 2 mist → 60% ≥ 50% threshold for i in range(3): (tmp_path / f"kt{i}.md").write_bytes(_SOURCE_NOTE) for i in range(2): (tmp_path / f"mist{i}.md").write_bytes(_NO_FRONTMATTER_NOTE) result = classify_repo(tmp_path) assert result.domain == DOMAIN_KNOWTATION assert result.rule == "majority_vote" assert abs(result.confidence - 0.6) < 1e-9 def test_below_threshold_is_mist(self, tmp_path: pathlib.Path) -> None: # 2 knowtation, 6 mist → 25% < 50% for i in range(2): (tmp_path / f"kt{i}.md").write_bytes(_SOURCE_NOTE) for i in range(6): (tmp_path / f"mist{i}.md").write_bytes(_NO_FRONTMATTER_NOTE) result = classify_repo(tmp_path) assert result.domain == DOMAIN_MIST assert result.rule == "fallback_mist" def test_exactly_at_threshold(self, tmp_path: pathlib.Path) -> None: # Exactly 50% → knowtation (≥ threshold) for i in range(5): (tmp_path / f"kt{i}.md").write_bytes(_SOURCE_NOTE) for i in range(5): (tmp_path / f"mist{i}.md").write_bytes(_NO_FRONTMATTER_NOTE) result = classify_repo(tmp_path) assert result.domain == DOMAIN_KNOWTATION def test_nested_notes_are_found(self, tmp_path: pathlib.Path) -> None: (tmp_path / "projects" / "ai").mkdir(parents=True) (tmp_path / "projects" / "ai" / "research.md").write_bytes(_PROJECT_DATE_NOTE) result = classify_repo(tmp_path) assert result.domain == DOMAIN_KNOWTATION # =========================================================================== # ClassificationResult — property and dataclass tests # =========================================================================== class TestClassificationResult: """ClassificationResult must behave correctly as a dataclass.""" def test_is_knowtation_true(self) -> None: r = ClassificationResult(domain=DOMAIN_KNOWTATION, rule="source_key") assert r.is_knowtation def test_is_knowtation_false(self) -> None: r = ClassificationResult(domain=DOMAIN_MIST, rule="fallback_mist", confidence=0.0) assert not r.is_knowtation def test_default_confidence_is_one(self) -> None: r = ClassificationResult(domain=DOMAIN_KNOWTATION, rule="source_key") assert r.confidence == 1.0 def test_detail_defaults_empty(self) -> None: r = ClassificationResult(domain=DOMAIN_KNOWTATION, rule="source_key") assert r.detail == "" # =========================================================================== # classify_many and knowtation_fraction helpers # =========================================================================== class TestClassifyMany: """classify_many must apply classify_note to every entry.""" def test_returns_result_for_each_note(self) -> None: notes = { "a.md": _SOURCE_NOTE, "b.md": _NO_FRONTMATTER_NOTE, } results = classify_many(notes) assert set(results) == {"a.md", "b.md"} assert results["a.md"].domain == DOMAIN_KNOWTATION assert results["b.md"].domain == DOMAIN_MIST def test_empty_dict_returns_empty(self) -> None: assert classify_many({}) == {} class TestKnowtationFraction: """knowtation_fraction must return the correct ratio.""" def test_all_knowtation(self) -> None: notes = {f"n{i}.md": _SOURCE_NOTE for i in range(10)} assert knowtation_fraction(notes) == 1.0 def test_none_knowtation(self) -> None: notes = {f"n{i}.md": _NO_FRONTMATTER_NOTE for i in range(5)} assert knowtation_fraction(notes) == 0.0 def test_mixed_fraction(self) -> None: notes = { "a.md": _SOURCE_NOTE, "b.md": _PROJECT_DATE_NOTE, "c.md": _NO_FRONTMATTER_NOTE, "d.md": _EMPTY_NOTE, } assert abs(knowtation_fraction(notes) - 0.5) < 1e-9 def test_empty_dict_returns_zero(self) -> None: assert knowtation_fraction({}) == 0.0 # =========================================================================== # Priority ordering — higher rules must beat lower ones # =========================================================================== class TestPriorityCascade: """Verify that rules fire in the declared priority order.""" def test_source_beats_project_date(self) -> None: # A note with both 'source' AND 'project'+'date' must fire rule 1 (source_key), # not rule 3 (project_or_tags_plus_date). note = b"---\nsource: telegram\nproject: ai\ndate: 2026-01-01\n---\n# T" result = classify_note(note) assert result.rule == "source_key" def test_source_id_beats_project_date(self) -> None: note = b"---\nsource_id: x\nproject: ai\ndate: 2026-01-01\n---\n# T" result = classify_note(note) assert result.rule == "source_id_key" def test_source_beats_source_id(self) -> None: # Both 'source' and 'source_id' present — rule 1 fires first. note = b"---\nsource: slack\nsource_id: msg-1\n---\n# T" result = classify_note(note) assert result.rule == "source_key" def test_tags_plus_date_is_rule3_not_fallback(self) -> None: note = b"---\ntags: [a, b]\ndate: 2026-05-12\n---\n# T" result = classify_note(note) assert result.rule == "project_or_tags_plus_date" assert result.domain == DOMAIN_KNOWTATION # =========================================================================== # Edge cases # =========================================================================== class TestEdgeCases: """Edge cases: binary content, BOM, CRLF, deeply nested vault.""" def test_binary_content_no_frontmatter(self) -> None: binary = bytes(range(256)) result = classify_note(binary) assert result.domain == DOMAIN_MIST def test_frontmatter_with_crlf_line_endings(self) -> None: note = b"---\r\nsource: telegram\r\ndate: 2026-01-01\r\n---\r\n# T" # CRLF endings — YAML key regex should still match keys = extract_frontmatter_keys(note) # source key may or may not be found depending on CRLF handling; # what matters is we do not crash assert isinstance(keys, frozenset) def test_very_long_frontmatter_does_not_hang(self) -> None: # 500-key frontmatter should parse quickly lines = [f"key{i}: value{i}" for i in range(500)] fm = b"---\n" + "\n".join(lines).encode() + b"\nsource: telegram\n---\n# T" result = classify_note(fm) assert result.domain == DOMAIN_KNOWTATION def test_note_where_source_key_is_value_not_key(self) -> None: # 'source' appears only as a frontmatter VALUE — not a key note = b"---\ncategory: source\ndate: 2026-01-01\n---\n# T" keys = extract_frontmatter_keys(note) assert "category" in keys assert "source" not in keys def test_classify_repo_ignores_hidden_dirs(self, tmp_path: pathlib.Path) -> None: # .obsidian folder with .md files must be skipped hidden = tmp_path / ".obsidian" hidden.mkdir() (hidden / "workspace.md").write_bytes(_SOURCE_NOTE) # Only add a mist note at the root (tmp_path / "plain.md").write_bytes(_NO_FRONTMATTER_NOTE) result = classify_repo(tmp_path) assert result.domain == DOMAIN_MIST # the hidden .md was ignored # =========================================================================== # Data-integrity: real Knowtation vault # =========================================================================== _REAL_VAULT = pathlib.Path("/Users/aaronrenecarvajal/knowtation/vault") _REAL_VAULT_AVAILABLE = _REAL_VAULT.is_dir() @pytest.mark.skipif( not _REAL_VAULT_AVAILABLE, reason="Real Knowtation vault not present — skipping data-integrity test", ) class TestRealVaultDataIntegrity: """All real vault notes must classify ≥99% as knowtation. The Knowtation vault at /Users/aaronrenecarvajal/knowtation/vault/ is the production vault. Every note there should satisfy at least one of rules 1–3. A ≥99% pass rate leaves room for a single "bare" note that has no knowtation frontmatter yet (e.g. a quick draft). Plan §1.2 calls for "1000-note sample → ≥99% classified knowtation". The actual vault has 84 notes at the time of implementation; we test all of them and note the divergence. The threshold is unchanged at 99%. """ # Structural directories that hold scaffold/documentation files, not # authored vault notes. Per SPEC §1, these are optional with user-defined # semantics and are explicitly NOT the content directories (inbox/, projects/, # areas/). Template READMEs have no frontmatter by design. _SKIP_DIRS: frozenset[str] = frozenset({"templates", "meta"}) def _load_vault_notes(self) -> dict[str, bytes]: notes: dict[str, bytes] = {} for md_path in _REAL_VAULT.rglob("*.md"): # Skip structural scaffold directories. parts = md_path.relative_to(_REAL_VAULT).parts if parts and parts[0] in self._SKIP_DIRS: continue try: notes[str(md_path.relative_to(_REAL_VAULT))] = md_path.read_bytes() except OSError: pass return notes def test_vault_note_count_is_positive(self) -> None: notes = self._load_vault_notes() assert len(notes) > 0, "Real vault returned no notes" def test_knowtation_fraction_meets_threshold(self) -> None: notes = self._load_vault_notes() fraction = knowtation_fraction(notes) total = len(notes) knowtation_count = round(fraction * total) assert fraction >= 0.99, ( f"Only {knowtation_count}/{total} vault notes classified as knowtation " f"({fraction:.1%} < 99% threshold). " f"Failing notes: " + str(sorted( path for path, content in notes.items() if not classify_note(content).is_knowtation )) ) def test_classify_repo_returns_knowtation_for_vault_root(self) -> None: result = classify_repo(_REAL_VAULT) assert result.domain == DOMAIN_KNOWTATION, ( f"classify_repo returned {result.domain!r} (rule={result.rule!r}): {result.detail}" ) def test_all_failing_notes_reported(self) -> None: """Collect and log any notes that do NOT classify as knowtation.""" notes = self._load_vault_notes() failures = { path: classify_note(content) for path, content in notes.items() if not classify_note(content).is_knowtation } if failures: # Emit as a warning (not failure — the 99% test above controls pass/fail). import warnings for path, result in sorted(failures.items()): warnings.warn( f"Note not classified as knowtation: {path!r} " f"(rule={result.rule!r}, detail={result.detail!r})", UserWarning, stacklevel=1, )