---
phase: 24-derived-lifecycle-health-maturity
plan: 01
type: tdd
wave: 1
depends_on: []
files_modified:
  - paperforge/worker/asset_state.py
  - tests/test_asset_state.py
autonomous: true
requirements:
  - STATE-01
  - STATE-02
  - STATE-03
  - STATE-04
  - AIC-01

must_haves:
  truths:
    - "compute_lifecycle(entry) returns correct lifecycle state for every combination of has_pdf / ocr_status / deep_reading_status / workspace paths"
    - "compute_health(entry) identifies concrete issues per dimension (pdf_health, ocr_health, note_health, asset_health) with actionable fix descriptions"
    - "compute_maturity(entry) returns level 1-6 with per-check pass/fail and blocking indicator"
    - "compute_next_step(entry) returns exactly one of: sync, ocr, repair, /pf-deep, rebuild index, ready"
    - "All four functions are pure — no side effects, no filesystem access, no imports of vault/path modules"
  artifacts:
    - path: "paperforge/worker/asset_state.py"
      provides: "Pure derivation functions for lifecycle, health, maturity, next-step"
      exports: ["compute_lifecycle", "compute_health", "compute_maturity", "compute_next_step"]
    - path: "tests/test_asset_state.py"
      provides: "Exhaustive test coverage for all four functions"
      min_lines: 80
  key_links:
    - from: "compute_maturity"
      to: "compute_lifecycle"
      via: "internal call (no duplicate derivation logic)"
      pattern: "compute_lifecycle\\(entry\\)"
    - from: "compute_next_step"
      to: "compute_lifecycle + compute_health"
      via: "internal call chain"
      pattern: "compute_lifecycle|compute_health"
---

<objective>
Create `paperforge/worker/asset_state.py` with four pure derivation functions that compute lifecycle state, health findings, maturity level, and next-step recommendations from a canonical index entry dict. All functions are side-effect-free and operate solely on entry dict fields.

Purpose: Provide the derivation engine consumed by asset_index.py (Plan 24-02). Separating pure logic from integration makes both testable independently.
Output: `paperforge/worker/asset_state.py` + `tests/test_asset_state.py` with full test coverage.
</objective>

<execution_context>
@C:/Users/Lin/.opencode/get-shit-done/workflows/execute-plan.md
@C:/Users/Lin/.opencode/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/ROADMAP.md §Phase 24 — Goal: "source-derived readiness, health findings, maturity, next steps"
@.planning/REQUIREMENTS.md — STATE-01..04, AIC-01
@.planning/phases/24-derived-lifecycle-health-maturity/24-CONTEXT.md — All 11 locked decisions (D-01 through D-11)
@paperforge/worker/asset_index.py — Entry dict structure from _build_entry() (lines 254-303)
@tests/conftest.py — Test conventions (pytest, tmp_path, _minimal_vault helper)
@tests/test_asset_index.py — Test patterns (class-per-function, _minimal_vault usage)

<interfaces>
<!-- Entry dict shape that all four functions consume. Extracted from asset_index.py _build_entry(). -->

From paperforge/worker/asset_index.py `_build_entry()` (lines 254-303):
```python
# Fields available in entry dict (all functions receive this dict as input):
entry = {
    "zotero_key": str,           # 8-char Zotero citation key
    "domain": str,                # e.g. "骨科"
    "title": str,
    "authors": list[str],
    "abstract": str,
    "journal": str,
    "year": str,
    "doi": str,
    "pmid": str,
    "collection_path": str,
    "collections": list[str],
    "collection_tags": list[str],
    "collection_group": list[str],
    "has_pdf": bool,             # True if any PDF attachments exist
    "pdf_path": str,             # Wikilink string e.g. "[[99_System/Zotero/storage/KEY/file.pdf]]" or ""
    "ocr_status": str,           # "pending" | "processing" | "done" | "failed" | "blocked" | "nopdf"
    "ocr_job_id": str,
    "ocr_md_path": str,          # Wikilink to OCR fulltext markdown
    "ocr_json_path": str,        # Path to OCR result.json
    "deep_reading_status": str,  # "pending" | "done"
    "note_path": str,            # Relative path to formal note e.g. "03_Resources/Literature/骨科/KEY - Title.md"
    "deep_reading_md_path": str, # Path to deep-reading content, or "" if not done
    # Workspace paths (Phase 22 D-01..D-05, Phase 23 D-12):
    "paper_root": str,           # "Literature/<domain>/<key> - <Title>/"
    "main_note_path": str,       # "<paper_root>/<key> - <Title>.md"
    "fulltext_path": str,        # "<paper_root>/fulltext.md"
    "deep_reading_path": str,    # "<paper_root>/deep-reading.md"
    "ai_path": str,              # "<paper_root>/ai/"
}
```

All functions are PURE: no filesystem access, no imports of vault/path modules.
</interfaces>
</context>

<feature>
  <name>Asset state derivation from canonical index entries</name>
  <files>paperforge/worker/asset_state.py, tests/test_asset_state.py</files>
  <behavior>
    All test cases below MUST be covered in the test file before any implementation is written.

    ### compute_lifecycle(entry) -> str

    Progressive states (deepest achieved wins):
    - **indexed**: has_pdf=False → "indexed" (all entries in index are at least indexed)
    - **pdf_ready**: has_pdf=True, ocr_status != "done" → "pdf_ready"
    - **fulltext_ready**: ocr_status == "done", deep_reading_status != "done" → "fulltext_ready"
    - **deep_read_done**: ocr_status == "done", deep_reading_status == "done", any workspace path missing → "deep_read_done"
    - **ai_context_ready**: ocr_status == "done" AND deep_reading_status == "done" AND all four paths present (ai_path, fulltext_path, deep_reading_path, main_note_path all non-empty) → "ai_context_ready"

    Test cases:
    - empty dict → "indexed" (no has_pdf key defaults to False)
    - has_pdf=False, ocr_status="pending", deep_reading_status="pending" → "indexed"
    - has_pdf=True, ocr_status="pending", deep_reading_status="pending" → "pdf_ready"
    - has_pdf=True, ocr_status="done", deep_reading_status="pending" → "fulltext_ready"
    - has_pdf=True, ocr_status="done", deep_reading_status="done", ai_path="" → "deep_read_done"
    - has_pdf=True, ocr_status="done", deep_reading_status="done", all workspace paths non-empty → "ai_context_ready"
    - has_pdf=True, ocr_status="done", deep_reading_status="done", fulltext_path="" (missing one) → "deep_read_done"
    - ocr_status="done_incomplete" (validated incomplete) → "pdf_ready" (ocr not truly done)

    ### compute_health(entry) -> dict[str, str]

    Returns dict with four keys. Each value is "healthy" or an issue description with fix instruction.

    - **pdf_health**: 
      - has_pdf=False → "healthy" (no PDF expected, no issue)
      - has_pdf=True, pdf_path empty string → "PDF path missing: run `paperforge sync` to regenerate"
      - has_pdf=True, pdf_path non-empty → "healthy"
    - **ocr_health**:
      - ocr_status == "done" → "healthy"
      - ocr_status == "pending" → "OCR pending: run `paperforge ocr`"
      - ocr_status == "processing" → "OCR in progress: wait for completion"
      - ocr_status == "failed" → "OCR failed: check meta.json error, then re-run `paperforge ocr`"
      - ocr_status == "blocked" → "OCR blocked: check PDF accessibility, then re-run `paperforge ocr`"
      - ocr_status == "nopdf" → "No PDF available for OCR: add PDF to Zotero entry"
      - ocr_status == "done_incomplete" → "OCR incomplete: results validation failed — re-run `paperforge ocr`"
    - **note_health**:
      - note_path empty → "Formal note missing: run `paperforge sync` to regenerate"
      - note_path non-empty → "healthy"
    - **asset_health**:
      - fulltext_path AND deep_reading_path AND main_note_path AND ai_path all non-empty → "healthy"
      - any path missing → list which paths are missing with fix: "Missing workspace paths: {list}. Run `paperforge sync` to regenerate"
      - all paths empty/missing → "Workspace not initialized: run `paperforge sync`"

    Test cases:
    - Empty entry: all defaults → pdf_health="healthy", ocr_health="OCR pending...", note_health="Formal note missing...", asset_health starts with "Missing workspace paths"
    - Fully healthy entry (has_pdf=True, ocr_status="done", note_path non-empty, all workspace paths present) → all four "healthy"
    - OCR failed entry → ocr_health contains "OCR failed"
    - Missing PDF path (has_pdf=True, pdf_path="") → pdf_health contains "PDF path missing"
    - Missing one workspace path → asset_health contains "Missing workspace paths: " and names the missing path(s)

    ### compute_maturity(entry) -> dict

    Returns:
    ```python
    {
        "level": int,          # 1-6
        "level_name": str,     # Human-readable level name
        "checks": {            # Per-check pass/fail
            "metadata": bool,  # title + authors present
            "pdf": bool,       # has_pdf == True
            "fulltext": bool,  # ocr_status == "done"
            "figure": bool,    # ocr done AND ocr_json_path non-empty (evidence of figure extraction)
            "ai": bool,        # lifecycle >= "ai_context_ready" (via compute_lifecycle)
            "review": bool,    # all checks pass (level 6)
        },
        "blocking": str | None,  # Which check name blocks next level, or None if at level 6
    }
    ```
    
    Level determination:
    - Level 1 (Metadata): always true, baseline
    - Level 2 (PDF): checks["metadata"] AND checks["pdf"]
    - Level 3 (Fulltext): Level 2 AND checks["fulltext"]
    - Level 4 (Figure): Level 3 AND checks["figure"]
    - Level 5 (AI): Level 4 AND checks["ai"]
    - Level 6 (Review Ready): Level 5 AND checks["review"]
    
    The maturity level is the highest level where all checks up to that level pass.
    compute_maturity(entry) MUST call compute_lifecycle(entry) internally — no duplicate logic.

    Test cases:
    - Empty entry → level=1, checks.metadata=True, checks.pdf=False, blocking="pdf"
    - has_pdf=True, ocr_status="pending" → level=2, checks.metadata=True, checks.pdf=True, checks.fulltext=False, blocking="fulltext"
    - has_pdf=True, ocr_status="done", ocr_json_path non-empty, deep_reading_status="pending" → level=4, blocking="ai"
    - Fully ready entry → level=6, blocking=None
    - Metadata check: title="" and authors=[] → checks.metadata=True (metadata check is lenient — entry exists means it has metadata)
    - Maturity never returns level < 1

    ### compute_next_step(entry) -> str

    Returns one of: "sync" | "ocr" | "repair" | "/pf-deep" | "rebuild index" | "ready"

    Priority-ordered decision:
    1. If ocr_status in ("failed", "blocked", "done_incomplete") → "ocr" (retry OCR)
    2. If has_pdf=False → "sync" (need to add PDF attachment in Zotero)
    3. If has_pdf=True AND ocr_status in ("pending", "nopdf") → "ocr"
    4. If has_pdf=True AND ocr_status in ("processing",) → "ready" (OCR already running, no action needed)
    5. If ocr_status == "done" AND deep_reading_status == "pending" → "/pf-deep"
    6. If note_path is empty → "sync"
    7. If any workspace path missing → "sync"
    8. Otherwise → "ready"

    Test cases:
    - has_pdf=False → "sync"
    - has_pdf=True, ocr_status="pending" → "ocr"
    - has_pdf=True, ocr_status="failed" → "ocr"
    - has_pdf=True, ocr_status="processing" → "ready"
    - has_pdf=True, ocr_status="done", deep_reading_status="pending" → "/pf-deep"
    - has_pdf=True, ocr_status="done", deep_reading_status="done", all paths present → "ready"
    - has_pdf=True, ocr_status="done", deep_reading_status="done", note_path="" → "sync"
    - ocr_status="done_incomplete" → "ocr"
    - ocr_status="blocked" → "ocr"
  </behavior>
  <implementation>
    Each function is a standalone pure function receiving an entry dict and returning a value.
    No side effects. No filesystem access. No imports from paperforge.config, paperforge.worker._utils, or any vault/path module.

    compute_maturity MUST call compute_lifecycle internally — do NOT duplicate the lifecycle derivation logic.
    compute_next_step MAY call compute_lifecycle and compute_health if useful, but can also derive directly from entry fields.

    Use .get() with defaults for all entry field accesses — entries may have missing keys from legacy or partial builds.

    Module docstring: describe the four exports and their contracts.
    Each function docstring: describe input, output, and the derivation rules.

    File header follows paperforge convention (see asset_index.py for style):
    ```python
    """Derived lifecycle, health, maturity, and next-step computation for canonical index entries.

    Pure functions — no side effects, no filesystem access.
    """
    from __future__ import annotations
    ```
  </implementation>
</feature>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: RED — Write failing tests for all four compute functions</name>
  <files>tests/test_asset_state.py</files>
  <read_first>
    - paperforge/worker/asset_index.py lines 254-303 (entry dict shape)
    - tests/conftest.py (test conventions)
    - tests/test_asset_index.py (class-per-function pattern)
    - .planning/phases/24-derived-lifecycle-health-maturity/24-CONTEXT.md (D-01..D-09 locked decisions)
  </read_first>
  <action>
    Create `tests/test_asset_state.py` with four test classes, one per function:

    **Class TestComputeLifecycle:**
    Write 8 test methods covering all states and edge cases listed in the `<behavior>` block above:
    - `test_empty_entry_returns_indexed` — entry={} → "indexed"
    - `test_no_pdf_returns_indexed` — has_pdf=False → "indexed"
    - `test_pdf_ready` — has_pdf=True, ocr_status="pending" → "pdf_ready"
    - `test_fulltext_ready` — ocr_status="done", deep_reading="pending" → "fulltext_ready"
    - `test_deep_read_done_with_missing_ai_path` — ocr done, deep_read done, ai_path="" → "deep_read_done"
    - `test_ai_context_ready` — all conditions met → "ai_context_ready"
    - `test_missing_one_workspace_path_returns_deep_read_done` — fulltext_path="" → "deep_read_done"
    - `test_ocr_done_incomplete_returns_pdf_ready` — ocr_status="done_incomplete" does NOT advance past pdf_ready

    **Class TestComputeHealth:**
    Write 4 test methods:
    - `test_empty_entry_all_unhealthy` — entry={} → pdf_health="healthy", ocr_health contains "OCR pending", note_health contains "Formal note missing", asset_health contains "Missing workspace paths"
    - `test_fully_healthy_entry` — has_pdf=True, ocr_status="done", note_path non-empty, all workspace paths present → all four "healthy"
    - `test_ocr_failed` — ocr_status="failed" → ocr_health contains "OCR failed"
    - `test_missing_pdf_path` — has_pdf=True, pdf_path="" → pdf_health contains "PDF path missing"

    **Class TestComputeMaturity:**
    Write 6 test methods:
    - `test_empty_entry_level_1` — level=1, blocking="pdf"
    - `test_pdf_only_level_2` — has_pdf=True, ocr_status="pending" → level=2, blocking="fulltext"
    - `test_fulltext_figure_level_4` — ocr done, ocr_json_path non-empty, deep_reading pending → level=4, blocking="ai"
    - `test_fully_ready_level_6` — all checks pass → level=6, blocking=None
    - `test_metadata_check_always_true` — empty title/authors still passes metadata check
    - `test_maturity_calls_lifecycle` — verify compute_maturity delegates to compute_lifecycle (mock/patch test)

    **Class TestComputeNextStep:**
    Write 8 test methods:
    - `test_no_pdf_returns_sync` → "sync"
    - `test_ocr_pending_returns_ocr` → "ocr"
    - `test_ocr_failed_returns_ocr` → "ocr"
    - `test_ocr_processing_returns_ready` → "ready"
    - `test_ready_for_deep_read_returns_pf_deep` → "/pf-deep"
    - `test_fully_ready_returns_ready` → "ready"
    - `test_missing_note_path_returns_sync` → "sync"
    - `test_ocr_done_incomplete_returns_ocr` → "ocr"

    **Commit:** `test(24-01): add failing tests for asset_state derivation functions`

    Run `pytest tests/test_asset_state.py -v` — all tests MUST FAIL (module doesn't exist yet).
  </action>
  <verify>
    <automated>pytest tests/test_asset_state.py -v 2>&1 | grep -E "ERROR|FAILED" && echo "ALL RED — tests fail as expected (module not yet created)"</automated>
  </verify>
  <acceptance_criteria>
    - tests/test_asset_state.py exists with 26 test methods across 4 test classes
    - File contains class TestComputeLifecycle with 8 test_* methods
    - File contains class TestComputeHealth with 4 test_* methods
    - File contains class TestComputeMaturity with 6 test_* methods
    - File contains class TestComputeNextStep with 8 test_* methods
    - Import line: `from paperforge.worker.asset_state import ...` (all four function names)
    - ALL tests FAIL when run with pytest (expected — no implementation yet)
    - No existing tests broken: `pytest tests/ -q --ignore=tests/test_asset_state.py` passes
  </acceptance_criteria>
  <done>Test file committed, all 26 tests failing with ImportError or AttributeError (module not created yet).</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: GREEN — Implement all four functions to pass tests</name>
  <files>paperforge/worker/asset_state.py</files>
  <read_first>
    - tests/test_asset_state.py (just written — the tests ARE the spec)
    - paperforge/worker/asset_index.py lines 254-303 (entry dict shape for reference)
    - .planning/phases/24-derived-lifecycle-health-maturity/24-CONTEXT.md §D-01..D-09 (locked decisions)
  </read_first>
  <action>
    Create `paperforge/worker/asset_state.py` with the four functions implemented to satisfy ALL test cases:

    ```python
    """Derived lifecycle, health, maturity, and next-step computation for canonical index entries.

    Pure functions — no side effects, no filesystem access.  Each function takes a
    canonical index entry dict (as produced by ``asset_index._build_entry()``) and
    returns a derived value.

    Exports:
      compute_lifecycle(entry) -> str    — Six progressive lifecycle states
      compute_health(entry) -> dict     — Per-dimension health findings with fix paths
      compute_maturity(entry) -> dict   — Level 1-6 with check breakdown
      compute_next_step(entry) -> str   — Recommended next action
    """
    from __future__ import annotations
    ```

    **compute_lifecycle(entry) -> str:**
    - Progressive whitelist approach: deepest achieved state from ["indexed", "pdf_ready", "fulltext_ready", "deep_read_done", "ai_context_ready"]
    - ai_context_ready: ocr_status == "done" AND deep_reading_status == "done" AND all of (ai_path, fulltext_path, deep_reading_path, main_note_path) are non-empty strings
    - deep_read_done: ocr_status == "done" AND deep_reading_status == "done" (but ai paths incomplete)
    - fulltext_ready: ocr_status == "done" (validated done, NOT done_incomplete)
    - pdf_ready: has_pdf == True
    - indexed: fallback (entry exists in index)
    - Use .get() with safe defaults for all field accesses

    **compute_health(entry) -> dict[str, str]:**
    - Return dict with keys: pdf_health, ocr_health, note_health, asset_health
    - PDF: has_pdf=False → "healthy"; has_pdf=True, pdf_path="" → "PDF path missing: run `paperforge sync` to regenerate"; otherwise "healthy"
    - OCR: map ocr_status to concrete message per D-05:
      - "done" → "healthy"
      - "pending" → "OCR pending: run `paperforge ocr`"
      - "processing" → "OCR in progress: wait for completion"
      - "failed" → "OCR failed: check meta.json error, then re-run `paperforge ocr`"
      - "blocked" → "OCR blocked: check PDF accessibility, then re-run `paperforge ocr`"
      - "nopdf" → "No PDF available for OCR: add PDF to Zotero entry"
      - "done_incomplete" → "OCR incomplete: results validation failed — re-run `paperforge ocr`"
      - default → "OCR pending: run `paperforge ocr`"
    - Note: note_path empty → "Formal note missing: run `paperforge sync` to regenerate"; otherwise "healthy"
    - Asset: check all four workspace paths (fulltext_path, deep_reading_path, main_note_path, ai_path); if any empty, list missing ones: "Missing workspace paths: fulltext_path, ai_path. Run `paperforge sync` to regenerate"; if all present → "healthy"

    **compute_maturity(entry) -> dict:**
    - MUST call compute_lifecycle(entry) internally — do NOT re-derive lifecycle
    - Build checks dict:
      - metadata: True (entry always has at minimum title from BBT)
      - pdf: bool(entry.get("has_pdf"))
      - fulltext: entry.get("ocr_status") == "done"
      - figure: entry.get("ocr_status") == "done" AND bool(entry.get("ocr_json_path", ""))
      - ai: lifecycle == "ai_context_ready"
      - review: lifecycle == "ai_context_ready" (all previous checks pass at this point)
    - Determine level: walk checks in order (metadata, pdf, fulltext, figure, ai, review). Level = last index where check is True. Level names: ["Metadata", "PDF Ready", "Fulltext Ready", "Figure Ready", "AI Ready", "Review Ready"]
    - blocking: name of first failing check after the achieved level, or None if level 6

    **compute_next_step(entry) -> str:**
    - Priority-ordered decision per D-09:
      1. ocr_status in ("failed", "blocked", "done_incomplete") → "ocr"
      2. not has_pdf → "sync"
      3. ocr_status in ("pending", "nopdf") → "ocr"
      4. ocr_status == "processing" → "ready"
      5. ocr_status == "done" AND deep_reading_status == "pending" → "/pf-deep"
      6. not note_path → "sync"
      7. any workspace path empty → "sync"
      8. otherwise → "ready"

    After implementation, run `pytest tests/test_asset_state.py -v` — ALL 26 tests MUST pass.

    **Commit:** `feat(24-01): implement asset_state derivation functions`
  </action>
  <verify>
    <automated>pytest tests/test_asset_state.py -v</automated>
  </verify>
  <acceptance_criteria>
    - paperforge/worker/asset_state.py exists and exports all four function names
    - compute_lifecycle, compute_health, compute_maturity, compute_next_step are importable: `from paperforge.worker.asset_state import compute_lifecycle, compute_health, compute_maturity, compute_next_step`
    - All 26 tests pass: `pytest tests/test_asset_state.py -v` exits 0
    - Existing tests pass: `pytest tests/ -q --ignore=tests/test_asset_state.py` exits 0 (or same count as before)
    - No imports from paperforge.config, paperforge.worker._utils, pathlib, or os in asset_state.py
    - compute_maturity contains a call to compute_lifecycle (grep for `compute_lifecycle\(` in asset_state.py)
    - Each function has a docstring describing input, output, and derivation rules
    - File uses `from __future__ import annotations` at top
  </acceptance_criteria>
  <done>All 26 tests pass. Four pure functions implemented and importable. No side effects or filesystem dependencies.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 3: REFACTOR — Polish, document, verify edge cases</name>
  <files>paperforge/worker/asset_state.py</files>
  <read_first>
    - paperforge/worker/asset_state.py (just implemented)
    - tests/test_asset_state.py (verify coverage is complete)
  </read_first>
  <action>
    Review and polish the implementation:
    
    1. **Docstring audit:** Each function docstring must describe:
       - What it computes
       - What input fields it reads from entry dict
       - What it returns (type and meaning)
       - Derivation rules (brief, not redundant with CONTEXT.md)
    
    2. **Module-level docstring:** List all four exports with one-line descriptions.
    
    3. **Edge case review:** Run `pytest tests/test_asset_state.py -v --tb=short` and verify:
       - No test uses mocking (pure functions don't need it except for the maturity-calls-lifecycle test)
       - Entry dicts with extra fields are handled correctly (functions ignore unknown fields via .get())
       - Entry dicts with None values are handled (treat None same as missing/empty)
    
    4. **Code style:** Follow paperforge conventions:
       - Type hints in docstrings (not inline, per existing convention)
       - 4-space indentation (no tabs)
       - Single blank line between functions
    
    5. **Final verification:** `pytest tests/ -q` passes all tests (existing + new).

    **Commit:** `refactor(24-01): polish asset_state docstrings and edge cases`
  </action>
  <verify>
    <automated>pytest tests/ -q</automated>
  </verify>
  <acceptance_criteria>
    - All tests pass: `pytest tests/ -q` exits 0
    - Module docstring lists all four exports
    - Each function has a descriptive docstring
    - No linting issues (ruff check paperforge/worker/asset_state.py passes or only pre-existing rule violations)
    - Code follows existing paperforge conventions (compare with asset_index.py style)
  </acceptance_criteria>
  <done>Module polished, all tests green, ready for integration in Plan 24-02.</done>
</task>

</tasks>

<verification>
All three TDD cycle tasks complete:
1. RED: 26 failing tests committed
2. GREEN: All 26 tests pass with clean implementation
3. REFACTOR: Docstrings, edge cases, style audit done
4. Full test suite passes: `pytest tests/ -q`
</verification>

<success_criteria>
- [x] `paperforge/worker/asset_state.py` exists with four pure functions
- [x] `tests/test_asset_state.py` exists with 26 tests covering all four functions
- [x] All tests pass (new + existing)
- [x] compute_maturity delegates to compute_lifecycle (no duplicate logic)
- [x] All functions use .get() with safe defaults (no KeyError on missing fields)
- [x] No filesystem or config imports in asset_state.py
- [x] Docstrings describe each function's contract
</success_criteria>

<output>
After completion, create `.planning/phases/24-derived-lifecycle-health-maturity/24-01-SUMMARY.md`
</output>
