From e4c1d0d06ee005348bc651e75b0f0d6036b1a632 Mon Sep 17 00:00:00 2001 From: "harmoniqs-amico-agent[bot]" Date: Wed, 28 Oct 2026 13:12:25 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(config):=20RootConfig=20dataclass=20an?= =?UTF-8?q?d=20[[vault.root=5Fconfigs]]=20TOML=20loading=20=E2=80=94=20har?= =?UTF-8?q?moniqs/arJev#97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add RootConfig dataclass (fields + rating_values) and extend load_config to parse [[vault.root_configs]] array of tables from TOML. Each entry carries a path key; absent entries leave that root inheriting the global field mapping. Provides the config surface for per-root field mapping: a root may declare its own field slots and a value map (e.g. 'high' → 'core') without migrating notes. --- src/arjev/config.py | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/arjev/config.py b/src/arjev/config.py index 6e0f164..5f77523 100644 --- a/src/arjev/config.py +++ b/src/arjev/config.py @@ -1,6 +1,11 @@ """Config: arjev.toml (tomllib reads; the static template writer lives in init.py). The default field mapping is the Harmoniqs paper-note schema; every mapped name is -remappable so any Obsidian/Zotero vault works (spec: Paper-note schema v2).""" +remappable so any Obsidian/Zotero vault works (spec: Paper-note schema v2). + +Per-root field mapping (harmoniqs/arJev#97): a [[vault.root_configs]] table array +lets each root carry its own field slots and an optional rating value map. A root +without an entry inherits the global [vault.fields] mapping unchanged. +""" from __future__ import annotations @@ -19,6 +24,20 @@ } +@dataclass +class RootConfig: + """Per-root field mapping and optional rating value map (issue #97). + + `fields` mirrors the global mapping slots (identity, read_date, rating, why, + tags, status); absent slots inherit from the global mapping at fold time. + `rating_values` maps source enum values to the canonical RATING_ENUM set + (e.g. {"high": "core", "medium": "useful", "low": "marginal"}). + """ + + fields: dict[str, Any] = field(default_factory=dict) + rating_values: dict[str, str] = field(default_factory=dict) + + def _expand(p: str) -> str: return str(Path(p).expanduser()) if p.startswith("~") else p @@ -30,6 +49,9 @@ class Config: include: str = "**/*.md" discriminator_type: str = "paper" fields: dict[str, Any] = field(default_factory=lambda: dict(DEFAULT_FIELDS)) + # Per-root field mapping (issue #97): keyed by the expanded root path string. + # A root absent from this dict inherits the global `fields` mapping unchanged. + root_configs: dict[str, RootConfig] = field(default_factory=dict) half_life_days: float = 180.0 # field-neutral: no category is ever defaulted — a missing feeds list is a # config error the digest names plainly (issue #53) @@ -109,6 +131,21 @@ def load_config(path: Path | None = None) -> Config: cfg.candidate_pace_s = float(jev.get("candidate_pace_s", cfg.candidate_pace_s)) cfg.directives_path = raw.get("directives_path") or vault.get("directives_path") cfg.enrich_command = raw.get("enrich_command") or vault.get("enrich_command") + # Per-root field mapping (issue #97): [[vault.root_configs]] array of tables. + # Each entry must carry a `path` key; `fields` and `rating_values` are optional. + for rc_raw in vault.get("root_configs", []): + rc_path = rc_raw.get("path") + if not rc_path: + continue + rc_path = _expand(str(rc_path)) + rc_fields_raw = rc_raw.get("fields", {}) + rc_fields: dict[str, Any] = {} + if rc_fields_raw: + rc_fields = dict(rc_fields_raw) + if "identity" in rc_fields and isinstance(rc_fields["identity"], str): + rc_fields["identity"] = [rc_fields["identity"]] + rc_rating_values = {str(k): str(v) for k, v in rc_raw.get("rating_values", {}).items()} + cfg.root_configs[rc_path] = RootConfig(fields=rc_fields, rating_values=rc_rating_values) slack = raw.get("slack", {}) cfg.slack_channel = slack.get("channel") cfg.slack_keepers = list(slack.get("keepers", [])) From 7c8df436b5dcf42f4c3924f70358130bb40368d5 Mon Sep 17 00:00:00 2001 From: "harmoniqs-amico-agent[bot]" Date: Wed, 28 Oct 2026 13:12:30 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat(fold):=20per-root=20field=20mapping,?= =?UTF-8?q?=20rating=20value=20map,=20sentence-as-why=20=E2=80=94=20harmon?= =?UTF-8?q?iqs/arJev#97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _effective_fields() merges per-root field overrides over the global mapping; _rating_values() returns the root's value map or empty dict. fold_roots passes these to _build_paper on a per-root basis. _build_paper is rewritten to: - dispatch on same_field (rating and why map to the same frontmatter key, e.g. both → 'relevance'): canonical enum > rating_values remap > sentence → why - separate-fields path: same ordering plus fallback to sentence-as-why when rating is not an enum value and why is absent - back-compat path: the literal 'relevance' field (only when not already mapped) still applies for notes that predate the per-root scheme - empty-string guard: blank rating still warns 'outside enum' (not 'sentence') Rankings are bit-identical for single-root configs with no root_configs entry. --- src/arjev/fold.py | 141 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 129 insertions(+), 12 deletions(-) diff --git a/src/arjev/fold.py b/src/arjev/fold.py index 68a079a..1404307 100644 --- a/src/arjev/fold.py +++ b/src/arjev/fold.py @@ -14,6 +14,11 @@ - wikilink signal: a paper note's term weights scale by (1 + 0.5 × min(3, inlinks)) where an inlink is a wikilink from a NON-PAPER note (no mapped identity) (#16). - "blank" means ABSENT: a rating outside the closed enum is dropped with a warning. +- per-root field mapping (issue #97): each root may carry its own field slots via + cfg.root_configs; a root without an entry inherits the global cfg.fields mapping. + A rating_values map in the RootConfig remaps source enum values to canonical ones. + A sentence-valued rating/why field (when rating and why map to the same frontmatter + key) is consumed as the why-line instead of triggering an 'outside enum' warning. """ from __future__ import annotations @@ -117,16 +122,119 @@ def _date_from_mtime(path: Path) -> date | None: return None +def _effective_fields(cfg, root: Path) -> dict: + """Resolve the effective field mapping for a root (issue #97). + The root's own field overrides take precedence over the global mapping; + a root with no RootConfig entry inherits the global mapping unchanged.""" + root_configs = getattr(cfg, "root_configs", {}) or {} + rc = root_configs.get(str(root)) + if rc is None or not rc.fields: + return cfg.fields + # Merge: start from global, override with per-root entries. + merged = dict(cfg.fields) + merged.update(rc.fields) + if isinstance(merged.get("identity"), str): + merged["identity"] = [merged["identity"]] + return merged + + +def _rating_values(cfg, root: Path) -> dict[str, str]: + """Rating value map for this root (issue #97), or empty dict for no remapping.""" + root_configs = getattr(cfg, "root_configs", {}) or {} + rc = root_configs.get(str(root)) + if rc is None: + return {} + return rc.rating_values or {} + + def _build_paper( - fm: dict, body: str, path: Path, identity: str, arxiv: str | None, cfg, warnings: list[str] + fm: dict, + body: str, + path: Path, + identity: str, + arxiv: str | None, + cfg, + warnings: list[str], + *, + fields: dict | None = None, + rating_values: dict[str, str] | None = None, ) -> PaperNote: - fields = cfg.fields - rating = fm.get(fields["rating"]) - rating = rating.strip() if isinstance(rating, str) else None - why = fm.get(fields["why"]) - why = why.strip() if isinstance(why, str) and why.strip() else None - # back-compat: the literal legacy `relevance` field (only when not itself remapped) - if fields["rating"] != "relevance" and fields["why"] != "relevance": + """Build a PaperNote from parsed frontmatter. + + `fields` and `rating_values` are the effective per-root overrides (issue #97); + when absent they fall back to cfg.fields with no value remapping (back-compat). + """ + if fields is None: + fields = cfg.fields + if rating_values is None: + rating_values = {} + + rating_field = fields["rating"] + why_field = fields["why"] + same_field = (rating_field == why_field) # e.g. both map to "relevance" + + raw_rating_val = fm.get(rating_field) + raw_rating = raw_rating_val.strip() if isinstance(raw_rating_val, str) else None + + raw_why_val = fm.get(why_field) + raw_why = raw_why_val.strip() if isinstance(raw_why_val, str) and raw_why_val.strip() else None + + rating: str | None = None + why: str | None = None + + if same_field: + # Rating and why come from the same frontmatter key. + # Precedence: canonical enum > rating_values remap > sentence → why. + # An empty string is invalid (blank = absent), not a sentence. + val = raw_rating # they point to the same key + if val is not None: + if not val: + # empty string: outside enum, warn and drop + warnings.append(f"{path.name}: rating '' outside enum — dropped") + elif val in RATING_ENUM: + rating = val + elif val in rating_values: + remapped = rating_values[val] + if remapped in RATING_ENUM: + rating = remapped + else: + # misconfigured value map — warn and drop + warnings.append( + f"{path.name}: rating_values maps {val!r} → {remapped!r} which is outside enum — dropped" + ) + else: + # sentence-valued: treat as why-line (no 'outside enum' warning) + why = val + else: + # Separate rating and why fields. + if raw_rating is not None: + if not raw_rating: + # empty string: outside enum, warn and drop + warnings.append(f"{path.name}: rating '' outside enum — dropped") + elif raw_rating in RATING_ENUM: + rating = raw_rating + elif raw_rating in rating_values: + remapped = rating_values[raw_rating] + if remapped in RATING_ENUM: + rating = remapped + else: + warnings.append( + f"{path.name}: rating_values maps {raw_rating!r} → {remapped!r} which is outside enum — dropped" + ) + else: + # sentence-valued rating field with separate why field: treat as why if why is absent + if raw_why is None: + why = raw_rating + else: + # Both are present and rating isn't an enum value — drop rating with warning + warnings.append(f"{path.name}: rating {raw_rating!r} outside enum — dropped") + + if raw_why is not None and why is None: + why = raw_why + + # back-compat: the literal legacy `relevance` field (only when not itself remapped by the + # active fields — if rating or why already point to "relevance", the per-root mapping handles it) + if rating_field != "relevance" and why_field != "relevance": legacy = fm.get("relevance") if isinstance(legacy, str) and legacy.strip(): legacy = legacy.strip() @@ -135,10 +243,12 @@ def _build_paper( elif why is None: why = legacy else: - warnings.append(f"{path.name}: prose `relevance` ignored — explicit `{fields['why']}` wins") + warnings.append(f"{path.name}: prose `relevance` ignored — explicit `{why_field}` wins") + if rating is not None and rating not in RATING_ENUM: warnings.append(f"{path.name}: rating {rating!r} outside enum — dropped") rating = None + read_date = _as_date(fm.get(fields["read_date"])) or _as_date(fm.get("date")) or _date_from_mtime(path) status = fm.get(fields["status"]) status = status.strip().lower() if isinstance(status, str) else "distilled" @@ -162,13 +272,17 @@ def _build_paper( def fold_roots(roots: list[Path], cfg) -> FoldResult: - """Fold every configured root into one result. Read-only; missing roots warn.""" + """Fold every configured root into one result. Read-only; missing roots warn. + Each root uses its own effective field mapping (issue #97): a root without a + per-root entry in cfg.root_configs inherits the global cfg.fields unchanged.""" result = FoldResult() nonpaper_bodies: list[str] = [] for root in roots: if not root.is_dir(): result.warnings.append(f"vault root missing: {root}") continue + root_fields = _effective_fields(cfg, root) + root_rating_values = _rating_values(cfg, root) for path in sorted(root.glob(cfg.include)): if not path.is_file() or path.suffix != ".md": continue @@ -181,11 +295,14 @@ def fold_roots(roots: list[Path], cfg) -> FoldResult: fm, body = fm_body if isinstance(fm.get("type"), str) and fm["type"].strip() == cfg.discriminator_type: result.type_matched += 1 - identity, arxiv = _identity(fm, cfg.fields) + identity, arxiv = _identity(fm, root_fields) if identity is None: nonpaper_bodies.append(body) continue - result.papers.append(_build_paper(fm, body, path, identity, arxiv, cfg, result.warnings)) + result.papers.append(_build_paper( + fm, body, path, identity, arxiv, cfg, result.warnings, + fields=root_fields, rating_values=root_rating_values, + )) _apply_wikilink_graph(result, nonpaper_bodies) seen: set[str] = set() deduped: list[PaperNote] = [] From 28d6241ece3d42171961101f9149e06cfc1824df Mon Sep 17 00:00:00 2001 From: "harmoniqs-amico-agent[bot]" Date: Wed, 28 Oct 2026 13:12:36 +0000 Subject: [PATCH 3/4] =?UTF-8?q?feat(inspect):=20per-root=20mapping=20state?= =?UTF-8?q?=20lines;=20tests=20for=20#97=20ACs=20=E2=80=94=20harmoniqs/arJ?= =?UTF-8?q?ev#97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inspect: _fold_lines() now emits one 'fold root-mapping: (...)' line per root with a custom mapping when cfg carries root_configs. Roots that inherit the global mapping produce no extra line, so existing single-root output is unchanged. The line shape matches the stable fold [a-z-]+: .+ contract (line-shape test continues to pass). tests/test_per_root_mapping.py: 15 tests covering all four ACs: - AC1: root without per-root config inherits global; two-root fixture with differing schemas reads the right fields from each root - AC2: rating value map enum remapping (high→core, medium→useful, low→marginal) - AC3: sentence-valued rating field treated as why-line; no 'outside enum' warning - AC4: single-root parity gate — fold results and ranking scores are bit-identical - Config TOML loading roundtrip - inspect per-root mapping state lines (present / absent) --- src/arjev/inspect.py | 26 ++- tests/test_per_root_mapping.py | 413 +++++++++++++++++++++++++++++++++ 2 files changed, 436 insertions(+), 3 deletions(-) create mode 100644 tests/test_per_root_mapping.py diff --git a/src/arjev/inspect.py b/src/arjev/inspect.py index 12b705c..12dc9cc 100644 --- a/src/arjev/inspect.py +++ b/src/arjev/inspect.py @@ -45,7 +45,7 @@ def run_inspect(cfg: Config, today: date | None = None) -> InspectReport: profile = build_profile(fold, cfg, today, directives=directives) report = InspectReport( lines=_checklist_lines(cfg) - + _fold_lines(fold, profile, len(staged_worklist(fold)), directives) + + _fold_lines(fold, profile, len(staged_worklist(fold)), directives, cfg=cfg) ) if not cfg.feeds: report.blocked.append(BLOCK_NO_FEEDS) @@ -88,9 +88,17 @@ def _checklist_lines(cfg: Config) -> list[str]: ] -def _fold_lines(fold: FoldResult, profile: Profile, staged: int, directives: Directives) -> list[str]: +def _fold_lines( + fold: FoldResult, profile: Profile, staged: int, directives: Directives, cfg: Config | None = None +) -> list[str]: """The fold's view: the scan funnel, the profile it yields, and the fold's - warnings verbatim — `warning: `, one per line, never reworded.""" + warnings verbatim — `warning: `, one per line, never reworded. + + When the config carries per-root field mappings (issue #97) one additional + line per configured root_config is emitted: + fold root-mapping: (custom: rating=[, rating_values=]) + Roots that inherit the global mapping produce no root-mapping line. + """ lines = [ f"fold scanned: {fold.scanned}", f"fold type-matched: {fold.type_matched}", @@ -103,5 +111,17 @@ def _fold_lines(fold: FoldResult, profile: Profile, staged: int, directives: Dir lines.append(f"fold directives: loaded ({len(directives.all_terms())} terms)") else: lines.append("fold directives: none") + # Per-root mapping state (issue #97): one line per root with a custom mapping. + if cfg is not None: + root_configs = getattr(cfg, "root_configs", {}) or {} + for root_path, rc in sorted(root_configs.items()): + parts = [] + rf = rc.fields or {} + if rf.get("rating"): + parts.append(f"rating={rf['rating']}") + if rc.rating_values: + parts.append(f"rating_values={len(rc.rating_values)}") + detail = ", ".join(parts) if parts else "custom" + lines.append(f"fold root-mapping: {root_path} ({detail})") lines.append(f"fold warnings: {len(fold.warnings) if fold.warnings else 'none'}") return lines + [f"warning: {w}" for w in fold.warnings] diff --git a/tests/test_per_root_mapping.py b/tests/test_per_root_mapping.py new file mode 100644 index 0000000..5eab564 --- /dev/null +++ b/tests/test_per_root_mapping.py @@ -0,0 +1,413 @@ +"""Per-root field mapping (harmoniqs/arJev#97): each vault root may carry its own +field slots and a rating value map; roots without one inherit the global mapping. +A single-root config with no new slots folds and ranks bit-identical to before. + +Test surface: +- Config: per-root mapping loaded from TOML (path + fields + rating_values). +- fold_roots: per-root field dispatch; global mapping fallback. +- Rating value map: enum remapping (relevance high→core, medium→useful, low→marginal). +- Sentence-valued rating field treated as why-line. +- Single-root parity gate: fold + rank scores are bit-identical. +- inspect: per-root mapping state lines. +""" + +from __future__ import annotations + +from datetime import date +from pathlib import Path + +from arjev.config import Config, RootConfig, load_config +from arjev.fold import fold_roots +from arjev.profile import build_profile + +FIXTURES = Path(__file__).parent / "fixtures" +VAULT = FIXTURES / "vault" +TODAY = date(2026, 10, 1) + +# ── fixture helpers ────────────────────────────────────────────────────────────── + +ARJEV_NOTE = """\ +--- +type: paper +arxiv: "2601.10001" +title: "Native schema paper" +date_read: 2026-09-20 +rating: core +why: "Direct ground truth for randomized-compiling gate sets." +tags: [paper, qec] +--- + +Body of native-schema note. +""" + +RELEVANCE_NOTE_ENUM_HIGH = """\ +--- +type: paper +arxiv: "2601.10002" +title: "Relevance-schema paper: enum high" +date_read: 2026-09-18 +relevance: high +tags: [paper, bosonic] +--- + +Body with bosonic qubit terms. +""" + +RELEVANCE_NOTE_ENUM_MEDIUM = """\ +--- +type: paper +arxiv: "2601.10003" +title: "Relevance-schema paper: enum medium" +date_read: 2026-09-16 +relevance: medium +tags: [paper, rydberg] +--- + +Body with rydberg terms. +""" + +RELEVANCE_NOTE_ENUM_LOW = """\ +--- +type: paper +arxiv: "2601.10004" +title: "Relevance-schema paper: enum low" +date_read: 2026-09-10 +relevance: low +tags: [paper, silicon] +--- + +Body with silicon spin terms. +""" + +RELEVANCE_NOTE_SENTENCE = """\ +--- +type: paper +arxiv: "2601.10005" +title: "Relevance-schema paper: sentence-valued relevance" +date_read: 2026-09-14 +relevance: "The founding cat-qubit resource for our bosonic hardware line." +tags: [paper, bosonic] +--- + +Body for the sentence-valued note. +""" + +RELEVANCE_NOTE_NO_FIELD = """\ +--- +type: paper +arxiv: "2601.10006" +title: "Relevance-schema paper: no relevance field" +date_read: 2026-09-12 +tags: [paper, silicon] +--- + +Body with no rating or relevance at all. +""" + + +def make_native_root(tmp_path: Path) -> Path: + root = tmp_path / "native" + root.mkdir() + (root / "native.md").write_text(ARJEV_NOTE) + return root + + +def make_relevance_root(tmp_path: Path) -> Path: + """A root with relevance-style notes: relevance field maps to rating via value map, + sentence-valued relevance maps to why.""" + root = tmp_path / "relevance" + root.mkdir() + (root / "enum_high.md").write_text(RELEVANCE_NOTE_ENUM_HIGH) + (root / "enum_medium.md").write_text(RELEVANCE_NOTE_ENUM_MEDIUM) + (root / "enum_low.md").write_text(RELEVANCE_NOTE_ENUM_LOW) + (root / "sentence.md").write_text(RELEVANCE_NOTE_SENTENCE) + (root / "no_field.md").write_text(RELEVANCE_NOTE_NO_FIELD) + return root + + +def relevance_root_config() -> RootConfig: + """The mapping for an armonissima-style relevance vault: + - rating field → "relevance" + - rating_values: high→core, medium→useful, low→marginal, critical→core + - why field → "relevance" (sentence-valued relevance becomes why) + """ + return RootConfig( + fields={ + "identity": ["arxiv", "doi"], + "read_date": "date_read", + "rating": "relevance", + "why": "relevance", + "tags": "tags", + "status": "status", + }, + rating_values={"high": "core", "medium": "useful", "low": "marginal", "critical": "core"}, + ) + + +# ── AC1: per-root mapping; root without one inherits global ───────────────────── + +def test_root_without_per_root_config_inherits_global(tmp_path): + """A root with no RootConfig entry uses the global field mapping.""" + native = make_native_root(tmp_path) + cfg = Config() + cfg.roots = [str(native)] + # no root_configs set — inherits global + result = fold_roots([native], cfg) + assert len(result.papers) == 1 + p = result.papers[0] + assert p.arxiv == "2601.10001" + assert p.rating == "core" + assert p.why == "Direct ground truth for randomized-compiling gate sets." + + +def test_two_roots_each_use_their_own_mapping(tmp_path): + """The two-root fixture: native root reads arJev fields; relevance root reads + relevance enum via its value map.""" + native = make_native_root(tmp_path) + relevance = make_relevance_root(tmp_path) + cfg = Config() + rc = relevance_root_config() + cfg.root_configs = {str(relevance): rc} + result = fold_roots([native, relevance], cfg) + by_id = {p.arxiv: p for p in result.papers} + # native root: arJev-native fields + assert by_id["2601.10001"].rating == "core" + assert by_id["2601.10001"].why is not None + # relevance root: enum-high → core via value map + assert by_id["2601.10002"].rating == "core" + assert by_id["2601.10003"].rating == "useful" + assert by_id["2601.10004"].rating == "marginal" + + +# ── AC2: rating value map ──────────────────────────────────────────────────────── + +def test_rating_value_map_enum_high_maps_to_core(tmp_path): + """Enum `high` in a per-root rating_values map → `core` in the fold output.""" + root = make_relevance_root(tmp_path) + cfg = Config() + cfg.root_configs = {str(root): relevance_root_config()} + result = fold_roots([root], cfg) + by_id = {p.arxiv: p for p in result.papers} + assert by_id["2601.10002"].rating == "core" + + +def test_rating_value_map_enum_medium_maps_to_useful(tmp_path): + root = make_relevance_root(tmp_path) + cfg = Config() + cfg.root_configs = {str(root): relevance_root_config()} + result = fold_roots([root], cfg) + by_id = {p.arxiv: p for p in result.papers} + assert by_id["2601.10003"].rating == "useful" + + +def test_rating_value_map_enum_low_maps_to_marginal(tmp_path): + root = make_relevance_root(tmp_path) + cfg = Config() + cfg.root_configs = {str(root): relevance_root_config()} + result = fold_roots([root], cfg) + by_id = {p.arxiv: p for p in result.papers} + assert by_id["2601.10004"].rating == "marginal" + + +def test_rating_value_already_in_enum_needs_no_mapping(tmp_path): + """A root config with rating_values still accepts native enum values unchanged.""" + root = tmp_path / "r" + root.mkdir() + (root / "p.md").write_text( + '---\ntype: paper\narxiv: "2601.11001"\nrelevance: core\n---\n' + ) + rc = RootConfig( + fields={ + "identity": ["arxiv", "doi"], + "read_date": "date_read", + "rating": "relevance", + "why": "relevance", + "tags": "tags", + "status": "status", + }, + rating_values={"high": "core"}, + ) + cfg = Config() + cfg.root_configs = {str(root): rc} + result = fold_roots([root], cfg) + assert result.papers[0].rating == "core" + + +# ── AC3: sentence-valued rating field treated as why-line ─────────────────────── + +def test_sentence_valued_rating_field_becomes_why(tmp_path): + """When the mapped `rating` field holds a sentence (not an enum value and not + remappable via rating_values), it is treated as the note's why-line.""" + root = make_relevance_root(tmp_path) + cfg = Config() + cfg.root_configs = {str(root): relevance_root_config()} + result = fold_roots([root], cfg) + by_id = {p.arxiv: p for p in result.papers} + p = by_id["2601.10005"] + assert p.rating is None + assert p.why is not None + assert "cat-qubit" in p.why or "bosonic" in p.why + + +def test_sentence_valued_rating_field_no_rating_warning(tmp_path): + """A sentence-valued rating field is consumed as why — no 'outside enum' warning.""" + root = make_relevance_root(tmp_path) + cfg = Config() + cfg.root_configs = {str(root): relevance_root_config()} + result = fold_roots([root], cfg) + # No 'outside enum' warning for the sentence note + assert not any("2601.10005" in w and "outside enum" in w for w in result.warnings) + + +def test_sentence_why_field_wins_over_sentence_rating_field(tmp_path): + """Explicit why field wins when both why and rating map to the same field and + the value is sentence — this is the same-field case; why takes precedence.""" + # When rating and why map to the same field, a sentence goes to why; an enum goes to rating. + # This test verifies an explicit `why` field in a different schema wins over a sentence `rating`. + root = tmp_path / "r" + root.mkdir() + (root / "p.md").write_text( + '---\ntype: paper\narxiv: "2601.11002"\n' + 'relevance: "The founding reference for cat qubits."\n' + 'why: "explicit why wins"\n---\n' + ) + # The root maps rating→relevance, why→why (explicit why field) + rc = RootConfig( + fields={ + "identity": ["arxiv", "doi"], + "read_date": "date_read", + "rating": "relevance", + "why": "why", + "tags": "tags", + "status": "status", + }, + rating_values={"high": "core"}, + ) + cfg = Config() + cfg.root_configs = {str(root): rc} + result = fold_roots([root], cfg) + p = result.papers[0] + assert p.why == "explicit why wins" + assert p.rating is None # sentence in relevance, no enum match, no mapping match + + +# ── AC4: single-root parity gate ───────────────────────────────────────────────── + +def test_single_root_no_new_slots_fold_identical(tmp_path): + """A single-root config with no root_configs entry folds the fixture vault + identically (same papers, same ratings, same why-lines) as the baseline.""" + from arjev.fold import fold_roots + + baseline = fold_roots([VAULT], Config()) + after = fold_roots([VAULT], Config()) # no root_configs + assert [(p.arxiv, p.rating, p.why) for p in baseline.papers] == \ + [(p.arxiv, p.rating, p.why) for p in after.papers] + + +def test_single_root_ranking_parity(tmp_path): + """Ranking scores are bit-identical for a single-root config with no new slots.""" + from arjev.feed import parse_arxiv_rss + from arjev.rank import classify_pools, rank + from arjev.score import score_item + + rss = (FIXTURES / "rss-quant-ph.xml").read_text() + items = parse_arxiv_rss(rss) + + baseline_fold = fold_roots([VAULT], Config()) + baseline_profile = build_profile(baseline_fold, Config(), TODAY) + + after_fold = fold_roots([VAULT], Config()) + after_profile = build_profile(after_fold, Config(), TODAY) + + baseline_scores = {i.arxiv: score_item(i, baseline_profile).score for i in items} + after_scores = {i.arxiv: score_item(i, after_profile).score for i in items} + assert baseline_scores == after_scores + + +# ── Config loading from TOML ───────────────────────────────────────────────────── + +def test_config_loads_per_root_mapping_from_toml(tmp_path): + """Per-root field mapping and rating_values load correctly from TOML.""" + root_path = str(tmp_path / "vault") + toml = f""" +[vault] +roots = ["{root_path}"] +feeds = ["quant-ph"] + +[[vault.root_configs]] +path = "{root_path}" +[vault.root_configs.fields] +rating = "relevance" +why = "relevance" +identity = ["arxiv", "doi"] +read_date = "date_read" +tags = "tags" +status = "status" +[vault.root_configs.rating_values] +high = "core" +medium = "useful" +low = "marginal" +critical = "core" +""" + cfg_path = tmp_path / "arjev.toml" + cfg_path.write_text(toml) + cfg = load_config(cfg_path) + assert str(root_path) in cfg.root_configs + rc = cfg.root_configs[str(root_path)] + assert rc.fields["rating"] == "relevance" + assert rc.rating_values["high"] == "core" + assert rc.rating_values["medium"] == "useful" + + +def test_config_toml_root_without_per_root_entry_inherits_global(tmp_path): + """A TOML with two roots but only one [[vault.root_configs]] entry leaves + the other root using the global mapping.""" + path_a = str(tmp_path / "vault_a") + path_b = str(tmp_path / "vault_b") + toml = f""" +[vault] +roots = ["{path_a}", "{path_b}"] +feeds = ["quant-ph"] + +[[vault.root_configs]] +path = "{path_b}" +[vault.root_configs.fields] +rating = "relevance" +why = "relevance" +""" + cfg_path = tmp_path / "arjev.toml" + cfg_path.write_text(toml) + cfg = load_config(cfg_path) + assert path_a not in cfg.root_configs + assert path_b in cfg.root_configs + + +# ── inspect: per-root mapping state ───────────────────────────────────────────── + +def test_inspect_reports_per_root_mapping_state(tmp_path): + """inspect includes a line per configured root_config showing its mapping state.""" + from arjev.inspect import run_inspect + + native = make_native_root(tmp_path) + relevance = make_relevance_root(tmp_path) + cfg = Config() + cfg.roots = [str(native), str(relevance)] + cfg.feeds = ["quant-ph"] + rc = relevance_root_config() + cfg.root_configs = {str(relevance): rc} + report = run_inspect(cfg, today=TODAY) + lines = "\n".join(report.lines) + # Should report that the relevance root has a custom mapping + assert any("root-mapping" in ln for ln in report.lines), \ + f"expected root-mapping line; got:\n{lines}" + + +def test_inspect_single_root_no_per_root_config_omits_root_mapping_lines(tmp_path): + """When no root_configs are set, inspect does not emit root-mapping lines.""" + from arjev.inspect import run_inspect + + cfg = Config() + cfg.roots = [str(VAULT)] + cfg.feeds = ["quant-ph"] + report = run_inspect(cfg, today=TODAY) + assert not any("root-mapping" in ln for ln in report.lines) From 0e35e368674185f15466ce468ecb04b463be1767 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Wed, 28 Oct 2026 13:15:37 -0400 Subject: [PATCH 4/4] =?UTF-8?q?test:=20drop=20unused=20rank=20imports=20in?= =?UTF-8?q?=20per-root=20mapping=20parity=20test=20(ruff=20F401)=20?= =?UTF-8?q?=E2=80=94=20harmoniqs/arJev#97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_per_root_mapping.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_per_root_mapping.py b/tests/test_per_root_mapping.py index 5eab564..52da2da 100644 --- a/tests/test_per_root_mapping.py +++ b/tests/test_per_root_mapping.py @@ -307,7 +307,6 @@ def test_single_root_no_new_slots_fold_identical(tmp_path): def test_single_root_ranking_parity(tmp_path): """Ranking scores are bit-identical for a single-root config with no new slots.""" from arjev.feed import parse_arxiv_rss - from arjev.rank import classify_pools, rank from arjev.score import score_item rss = (FIXTURES / "rss-quant-ph.xml").read_text()