Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ jobs:
- name: Require typed graph capabilities
run: python -c "from populace_dynamics.graph._compat import require_graph; require_graph()"
- name: Run graph and existing mortality regressions
run: pytest -q tests/test_graph_mortality.py tests/test_graph_mortality_trajectory.py tests/test_m6_engine_refit.py tests/test_m6_engine_steps.py
run: pytest -q tests/test_graph_mortality.py tests/test_graph_mortality_trajectory.py tests/test_graph_trajectory_accounting.py tests/test_m6_engine_refit.py tests/test_m6_engine_steps.py

# Fan-in jobs keeping the branch-protection context names
# ("pytest (3.11)" / "pytest (3.13)") stable across the shard split.
Expand Down
157 changes: 157 additions & 0 deletions docs/stock-flow-accounting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# Annual population stock-flow accounting

`populace_dynamics.engine.accounting.reconcile_period` checks whether one
annual transition's opening population, declared arrivals and departures,
and closing population reconcile. It reports person counts, weight flows,
weight changes, and arithmetic residuals without altering either frame.

This interface is experimental and opt-in. It does not change the historical
projection engine, generate demographic events, or establish an admitted
population. Its status is `engineering-accounting-coherence-only`, and its
interface version is `stock-flow-accounting/0.1.0-experimental`. No scientific
tolerance or acceptance gate is added.

## Input contract

```python
reconcile_period(
opening,
closing,
*,
opening_year,
closing_year,
additions=(),
exits=(),
)
```

Both pandas frames require `person_id`, `year`, and `weight` columns.
Identifiers are unique within each frame; IDs and years must be integers
within the signed int64 range. Floats and booleans are rejected as IDs or
years. These are the accountant's own validation rules, which are stricter
than the historical loop's slice check.

Weights must be real numeric, finite and nonnegative. Strings, complex
values, and booleans are rejected before conversion to binary64. Negative
weights are checked before conversion; nonzero values that underflow to zero
in binary64 are rejected. Zero-weight
rows remain visible in counts. Empty frames still require the three columns,
but empty columns may have any dtype. Each nonempty frame must carry its
stated year, and `closing_year` must equal `opening_year + 1`.

`additions` and `exits` are sequences of `PopulationEvent` declarations.
Each event has an integer `person_id`, `kind`, closing `year`, optional
`weight`, and optional string `reason` and `source`.

| Addition kinds | Exit kinds |
|---|---|
| `birth` | `death` |
| `scheduled_entry` | `emigration` |
| `other_entry` | `other_exit` |

The two `other_*` kinds require a nonempty reason. At most one addition and
one exit are supported for a person in a period, with arrival before exit.
There is no finer event-timing model. An addition cannot collide with the
opening roster, and a declared departure cannot remain in the closing roster.
Unexplained changes in endpoint membership and duplicate declarations fail.
The accountant never infers that a disappearing person died or that a new
identifier represents an immigrant.

## Counts and weights

Counts reconcile exactly:

```text
closing = opening + additions_total - exits_total
```

People who arrive and depart in the same period are counted in both flows.
These transients appear in neither endpoint frame and must have explicit
weights on both declarations. Otherwise, an omitted arrival weight uses the
closing-frame weight; an omitted departure weight uses the opening-frame
weight. These conventions are recorded in provenance through the counts of
explicit event weights.

Weight accounting reports:

```text
reconstructed_closing = opening + additions_total - exits_total + revaluation
weight_residual = closing - reconstructed_closing
```

| Revaluation component | Difference summed over the relevant people |
|---|---|
| `carried` | Closing weight minus opening weight for survivors |
| `entrant` | Closing weight minus declared arrival weight |
| `exiting` | Declared departure weight minus opening weight |
| `transient` | Declared departure weight minus declared arrival weight |

Weights are never rebalanced. Sums use `math.fsum` over binary64 components;
subtraction and component totals still involve rounding. Nonzero arithmetic
residuals are reported without a tolerance-based verdict. An intermediate or
summary that cannot be represented with finite binary64 arithmetic raises a
`PopulationAccountingInputError`.

## Example

```python
import pandas as pd
from populace_dynamics.engine.accounting import PopulationEvent, reconcile_period

opening = pd.DataFrame({
"person_id": [1, 2, 3], "year": [2020] * 3,
"weight": [10.0, 20.0, 30.0],
})
closing = pd.DataFrame({
"person_id": [1, 2, 90], "year": [2021] * 3,
"weight": [11.0, 20.0, 5.0],
})
account = reconcile_period(
opening, closing, opening_year=2020, closing_year=2021,
additions=[PopulationEvent(90, "birth", 2021, source="synthetic.birth")],
exits=[PopulationEvent(3, "death", 2021, source="synthetic.mortality")],
)
assert account.weights.closing == 36.0
assert account.weights.revaluation.carried == 1.0
assert account.count_residual == 0
assert account.weight_residual == 0.0
```

The weight identity is `60 + 5 - 30 + 1 = 36`. `account.to_dict()` returns
JSON-serializable counts, weights, residuals, and provenance. Person-ID tuples
are available as attributes and are omitted from this summary. Serialized
provenance is isolated from the immutable account and other serializations.

Malformed inputs raise `PopulationAccountingInputError`. Well-formed inputs
whose declarations conflict with the frames raise
`PopulationReconciliationError`, with typed `.discrepancies` and a `.to_dict()`
representation. Validation stops at the first failing stage: frames,
declarations, reconciliation, then weights.

## Projection-loop integration and limits

The caller must capture declarations from its adapters or supplied schedule.
The loop activates scheduled entries before mortality in the wave ending in
`Y`, while entry frames carry `Y - 1`. Their declarations must use `Y` and
explicit weights so an entrant who dies in the same wave can be accounted for.
Birth and mortality declarations should come from the operations that perform
those transitions. An endpoint difference alone cannot establish their cause.

Tests in `tests/test_m6_stock_flow.py` drive the real `ProjectionEngine` with
synthetic recording adapters, then reconcile adjacent output slices. They
include a birth, a death, and a scheduled entrant who dies before the wave
closes. No fitted transition law or native population is used.

Accounting coherence does not verify event-log completeness. If both records
for a transient are omitted, the endpoint frames cannot reveal the omission;
provenance records `event_log_completeness_verified=False`. Event reasons are
also caller assertions. The accountant has no cross-period memory, so a past
ID explicitly declared as a new addition can be reused without detection.
Only `weight` is reconciled; `start_weight` and other frame columns are ignored.

The module directly imports only the standard library, NumPy, and pandas.
Normal package import also executes the historical engine initializer and its
broader source dependencies. The historical source-identity test verifies that
this new module remains unreachable from the sealed projection roots; the
static guard covers ordinary imports and explicitly listed dynamic roots, not
arbitrary runtime imports. This page is not added to the Quarto chapter list.
89 changes: 89 additions & 0 deletions docs/trajectory-accounting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Accounting for annual mortality transitions

The optional `populace_dynamics.graph.trajectory_accounting` module adds one
accounting node per year to the existing synthetic mortality/ageing graph.
It executes the real Microcosm DAG with the same reviewed graph/Frame pin.
It does not change fitted laws, draws, population frames, weights, scientific
gates, or the historical projection engine.

```python
from populace_dynamics.graph.trajectory_accounting import (
run_accounted_mortality_trajectory,
)

result = run_accounted_mortality_trajectory(
training="synthetic-training.json",
rates="synthetic-rates.json",
initial="synthetic-initial.json",
holdouts={2015: "synthetic-annual-2015.json"},
end_year=2015,
output_dir="synthetic-accounting-output",
)
```

These paths must contain the exact synthetic contracts documented in
[the population graph and mortality trajectory](population-graph.md).
The optional module
requires Python 3.13 or later and the reviewed graph dependencies. Importing
the ordinary `populace_dynamics.graph` package remains lazy and unchanged.
`build_accounted_trajectory_graph` also exposes the declaration and kernel
registry for callers using the executor directly.

## Declared events and frozen snapshots

Each `account_YEAR` node reads only the corresponding typed mortality
transition and frozen population snapshot. It runs against the graph's
separate training population version without reading that population's
columns. It has no holdout, model, source, or RNG input. Accounting is not a
prerequisite of any subsequent mortality transition.

The adapter validates the transition's person and observation bindings and
the snapshot's year, row, period, and weight structure. It copies the opening
and closing rows, preserving the supported columns and weight positions.
Only transition records declaring `survives=false` produce death events.
Missing or additional endpoint people cannot supply their own event causes.
No births, migration, or other entry/exit events are assumed.

Completed transitions call the
[annual stock-flow accountant](stock-flow-accounting.md). Count conservation
is exact; weight residuals and revaluation components are reported without
an acceptance threshold. A changed survivor weight can therefore produce a
complete account while the original mortality evaluation independently
fails its unchanged-weight check. Accounting completion establishes neither
scientific acceptance nor completeness or truth of the declared event log.
The adapter cannot detect an arbitrary same-length rearrangement of supplied
weights without independent binding evidence; it accounts for the supplied,
content-addressed snapshot.

After complete extinction, subsequent completed empty periods receive explicit
zero-to-zero accounts even though the population contains no new period
groups. A failed or blocked mortality transition instead produces
`account=null` and `accounting_status=not_evaluated`, retaining its diagnostic
and last completed year. It never turns a stale snapshot into deaths.
Malformed inputs or reconciliation refusals produce a separate failed
accounting artifact; original mortality and evaluation receipts remain intact.

The runner writes `accounting-report.json` and the actual `manifest.json`.
Its `AccountedTrajectoryRun` contains those accounting summaries and the
manifest. It does not rewrite the original runner's `report.json`, model, or
trajectory exports. The manifest retains original application/evaluation
receipts and artifact references; an accounting status is not their rollup.
Source hashes cover the accountant and the reused snapshot/transition helpers.

## Household and location contract still required

The current initial source accepts exactly `person_id`, `age`, `sex`, and
`weight`. Its snapshot accepts only the existing person-period identity,
age, sex, period, and weight structure. Additional atomic-location or
household-link columns are refused, not silently discarded. This integration
does not yet transport household location or membership.

A separate extension must preserve household atomic-location columns **and
household/member links** in both source and snapshot contracts. Microcosm
assigns the household anchor once **before support clones are created**;
clones and Dynamics inherit it. Larger geographies must derive from that
anchor through the **same versioned mapping**. Location may change only
through a separate declared mobility or migration event. Accounting must not
allocate locations, create independent geography assignments, or infer a move
from a roster difference. That extension needs its own schema, lineage,
membership, mapping-version, and declared-event tests.
4 changes: 4 additions & 0 deletions scripts/first_estimates_birth_evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@
Path("src/populace_dynamics/graph/runtime.py"),
Path("src/populace_dynamics/graph/synthetic.py"),
Path("src/populace_dynamics/graph/trajectory.py"),
Path("src/populace_dynamics/graph/trajectory_accounting.py"),
# This opt-in accountant is unreachable from the historical projection.
# The existing engine loop, steps, and package initializer remain sealed.
Path("src/populace_dynamics/engine/accounting.py"),
)
POST_REVIEW_SHARED_SOURCE_BLOBS = {
Path(
Expand Down
Loading