Skip to content

feat: ADR-049 profile + observer, D5 resolution, and one conserving rounder (#533) - #537

Merged
Polichinel merged 5 commits into
developmentfrom
docs/533-adr049-profile-and-observers
Nov 6, 2026
Merged

Polichinel merged 5 commits into
developmentfrom
docs/533-adr049-profile-and-observers

Conversation

@Polichinel

Copy link
Copy Markdown
Contributor

Closes the three P0 governance items from the allocation dossier and lays the foundation for the
tools. Production emits exactly what it did before — every change is a new profile, a new
module, or a test.

Three commits: 3a88a2c (ADR-049 gets a profile and an observer), 02f0d44 (D5: sampling is a
viewpoint concern), b1af0f4 (one conserving rounder + the allocator spec).

Why this exists

Every effort in VIEWS that scores lr_sb on an affected window is blocked by one cell holding a
whole year of the Tigray war. The programme behind it was becoming a research agenda; this is the
turn back toward working tools.

The design move

Every allocation strategy is a weight function composed with one shared rounder:

a_e = LRM(v_e, w_e),     w_e(t,c) = τ_e(t) · σ_e(c)

Conservation (ADR-040 Invariant 1) is proved once, in the rounder, instead of re-argued per
strategy. A new allocation idea becomes a new τ or σ registered through the existing OCP seam
with no builder change. And the two axes compose by multiplication rather than sequential
re-rounding — strictly less rounding loss than ADR-049 §4's temporal-then-spatial prescription.

apportionment.py implements largest-remainder with five properties, each proved in the docstring
and tested: conservation · boundedness · determinism · zero-preservation · order preservation.

Zero-preservation is the substantive one — a month or cell with no supporting evidence never
receives mass
— and it's provable: the shortfall is a sum of fractional remainders each below 1, so
it can never exceed the count of strictly positive remainders. This replaces ceil_split's
inflation by construction.

Two defects found and fixed

1. The existing rounder wasn't largest-remainder. It ranked by weight, not fractional
remainder — for v=10 over (0.5, 0.25, 0.25) it returned (6, 2, 2) when cell 1's ideal share is
exactly 5.0. Conservation held; boundedness didn't. And ranking by weight sends the surplus to
the already-largest cell every time, which is the concentration bias ADR-049 lists among its own
negative consequences
. Zero parity risk: production_parity selects passthrough, which never
reaches that function.

2. refresh_pipeline.sh didn't say which profile production runs. Step 3 called
build_viewpoint.py with no --profile, so production's allocation policy came from an argparse
default in another file — a one-line edit elsewhere could have switched what production emits,
silently. Now named explicitly, with two guards. Zero behaviour change.

⭐ A third declared signal, unused anywhere — including in the legacy loader

event_clarity == 2 is UCDP stating that the reported count aggregates several incidents. It
covers 45.56% of all fatality mass — more than where_prec and date_prec combined. Crossed
with precision it separates the artefact class from real concentration:

clarity=2 clarity=1
imprecise A — 36.41% of mass C — 9.21%
precise B — 9.17% D — 45.20%

All four worked examples are in A. B must never be touched:

40,000  wp=1 dp=2 clarity=2   Murambi Sector - Principal Murambi site
24,173  wp=2 dp=2 clarity=2   El Fasher and along exit routes

Forty thousand deaths at one memorial site, April 1994, location known exactly. Magnitude cannot
distinguish Murambi from Tigray; this field can
— which is why views-hydranet and views-models
each independently refused to winsorise. It belongs to the declaration, not the allocator:
allocation is triggered by imprecision alone, and the existing precision gates already handle that.

⚠️ Recorded, not acted on: the support may be wrong, not just the weights

ADR-049 takes the target polygon from the centroid's admin-1. The events say otherwise:

113,368   "Tigray, Afar, Amhara"                             <- THREE regions
 79,456   "Northern Amhara, western-most Afar and Tigray"    <- THREE regions

We spread a three-region event over one region; 20.7% of KGI mass has a comma-separated
where_description. If the true support is ~3× wider, that is the size of the residual already
measured
— cell 148759 fell 7× all-history while the largest cell at month 504 stayed at 41,645.
So weight comparisons may be measuring the wrong thing. Added as option S3, scoped as a
hypothesis to check on top-mass events by hand rather than a general free-text parser.

Corrections to my own earlier claims, all from measurement

  • Denton (1971) does not reject T1 uniform. His objection is to the identity-matrix penalty
    when adjusting an existing sub-annual series; with no indicator his first-difference penalty
    reduces to uniform. T1 is the correct no-indicator baseline; T2 is the Chow–Lin case.
  • The detection critique is withdrawn. I argued best >= span was indefensible. Measured, it
    excludes 2,382 events / 2,560 fatalities / 0.06% of mass (median: one death over three
    months), and on the sources ceil_split actually runs on, 375 events / 209 fatalities. The
    condition protects against the inflation ceil would otherwise cause.
  • A test name lied. test_remainder_to_highest_weight passed under both rounding rules,
    because weight and remainder coincide in its case. Renamed, discriminating case added.

Mutation drills

Seven mutations across the new guards. Six caught immediately. One survived — removing the key
tie-break broke nothing, because sorted is stable and the determinism test had no tied leader.
Fixed with v=11 over two equal weights; now caught. (That's the second decorative guard of mine
the drill has caught, which is the argument for running it every time.)

What's deliberately not here

production_parity stays byte-identical and remains what the pipeline invokes — now explicitly.
kgi_distributed exists and is invoked by nobody. No sampler is built. No published figure moves.

Verification

ruff clean · mypy clean (90 source files) · 2,473 passed · 16 skipped · 17 xfailed · 0 failed

🤖 Generated with Claude Code

https://claude.ai/code/session_01EfeuULbPTqrx4qtNki5dy9

Polichinel and others added 5 commits November 4, 2026 10:43
ADR-049 was accepted 2026-06-28 and decided that geographically
imprecise UCDP events should be distributed across their target polygon
rather than dumped on a centroid cell, calling that the data factory's
recommended behaviour. For four months nothing connected that decision
to anything runnable, and its own golden criterion went unchecked while
the cell it names grew from 273,076 to 274,358 with nothing going red.

A correction first, because I got this wrong twice and it is recorded in
two places that are now amended. I claimed ADR-049 "was never
implemented". It was. proportional implements the mechanism in full,
with 46 unit tests, the builder wired to it, the weight map built
whenever the strategy is not passthrough, the GAUL crosswalks present,
and ViewpointConfig defaulting to proportional, which honours section 6
as written. A full weight-map build over the 2.73M-row store takes 3.1
seconds.

The real gap was narrower. production_parity was the only registered
UCDP profile and it overrides the default back to passthrough to
reproduce VIEWSER, which section 6 explicitly permits. But that left
the recommendation with no name anyone could select, compare or test
end to end, and no test asserted either choice, so the ADR and the
invoked configuration could drift in silence. That is C-365's mechanism:
a decision with no observer.

This closes the join without touching any published number.

kgi_distributed is registered. It differs from production_parity in
exactly one concern, which takes two fields because section 6 frames
drop, passthrough and distribute as three options on one mechanism:
spatial_distribution_strategy passthrough to proportional, and
exclude_where_prec (4, 6) to (). The temporal axis is left identical;
one variable at a time. Two consequences of the empty exclusion are
named in the profile comment: where_prec 7 and an undocumented
where_prec 99 also flow in, both routed to the country polygon.

tests/test_adr049_profile_contract.py is the observer. It pins every
strategy field of production_parity, that build_viewpoint.py still
defaults to it, that the config default still agrees with section 6,
that kgi_distributed differs in exactly those two fields, and ADR-049's
golden criterion on the real event: the centroid's share collapses, the
allocation matches the weight map cell by cell to the rounding
tolerance, and best, low and high are each conserved exactly.

The mutation drill caught one of my own guards being decorative. Four
mutations were applied; three were caught. The survivor concentrated the
whole total onto a different single cell, which left cell 148759 at zero
and so passed the "the centroid's share fell" assertion. A guard that
cannot distinguish distribution from relocation is not a guard for this
ADR, so test_allocation_is_proportional_to_the_weight_map was added; it
predicts every cell's allocation from its weight. Replaying the mutation
catches it, and a subtler one that doubles a single weight is caught
too, by that test and by conservation.

The measured effect, for the record: cell 148759 is in GAUL admin-1
1301, which has 18 weighted cells, and the cell's own weight is 0.0743.
So distribution gives it about 8,423 of the Tigray event's 113,368
instead of all of it, a 13.5x reduction, conserving the total exactly.

What this does not do: production still invokes production_parity, and
a test now asserts that, so switching cannot happen by diff. Switching
would move every published grid-level fatality figure for where_prec >=
4 events and invalidate the ADR-041 digests, and it should wait on
evidence that proportional weighting beats uniform allocation by enough
to justify its extra assumption, which has never been measured.

ruff and mypy clean; 2,443 passed, 16 skipped, 17 xfailed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfeuULbPTqrx4qtNki5dy9
)

Decision D5 in the allocation dossier. Croicu2026's Multiple Spatial
Imputation raised the question of whether a stochastic location model
belongs in this layer at all, and the answer turned out to be a
governance conflict rather than a judgement call.

ADR-014 Principle 1 names the category outright: "New survivorship
rules, new temporal distribution methods, new uncertainty models --
these are research outputs, not bugs." Its Principle 4 versioning
examples are "v2: Uncertainty-weighted temporal distribution,
proportional fatality spread" and "v3: Nowcasting-adjusted with
revision-informed confidence". ADR-049's Deferred section said the
opposite, placing sampling downstream. Two Accepted ADRs disagreed,
which ADR-000 does not permit.

Resolved in ADR-014's favour, because the deferral conflated producing
an uncertainty representation with consuming one. Producing m
realisations is ADR-014 Principle 4's existing model: m versioned
viewpoints, no new output shape at this layer. Consuming m is the
downstream concern, needing m grids and an ADR-050 contract that can
express a realisation axis, and that half of the caution stands.
Restated: "sampling belongs downstream" was wrong; "consumers must be
able to read m realisations before we emit them" is right.

ADR-014 Principle 3 then supplies the admissibility test. A sampler
introduces no information absent from the store, since the model is
fitted to the store's own precisely-located events, which section 7
already required. But purity holds only if the seed is configuration: a
sampler seeded from entropy breaks Principle 3 invisibly, because the
same build would produce different output with no recorded cause.

So ViewpointConfig.seed is declared, defaulting to None meaning "no
stochastic strategy in use", and written to the provenance ledger on
every build. It is deliberately unused today -- every registered
strategy is deterministic -- because stating the contract before the
first sampler arrives is cheaper than retrofitting it, and because a
reproducibility record should exist before it is needed rather than
after. A stochastic strategy must reject None rather than seed itself.

Three tests hold the contract, and the third is a tripwire designed to
fail: it asserts no strategy module has acquired randomness, so the
first sampler to land turns it red, which is the prompt to thread the
seed through. The mutation drill confirmed all three fire -- a sampler
landing, a real default seed, and the field's removal.

Scope admitted: fitting and sampling with a declared seed. Scope
withheld: m > 1, because no consumer has asked. The one request on
record asks for a per-cell-month precision flag, not m datasets, and
Croicu measures m=1 beating centroid placement in 100% of simulations.
Also withheld: building the sampler. This authorises nothing.

Measurement, so the next decision is cheaper. A full kgi_distributed
build takes 140s and 8.6GB peak RSS, turning 885,075 events into
2,414,552 rows against production_parity's 372,303 -- a 6.5x row
increase downstream would absorb. Cell 148759 falls from 274,358 to
39,221 all-history and from 113,395 to 13,866 at month 504. But the
largest state-based cell that month is still 41,645, about 36% of it:
proportional distribution reduces the concentration 2.7x and does not
remove it. A skepticism pre-registered in the dossier was that a remedy
might move the spike rather than remove it, and it partly fired before
the experiment was designed. That argues for continuing up Gotway2002's
change-of-support ladder, not for stopping at ADR-049.

If a sampler is ever built: 15,722 dyad-months contain a KGI event and
would need a fitted model, and 68,084 KGI events need placing. Only 298
KGI events, 0.4% and 7,981 fatalities, belong to a dyad with no
precisely-located events anywhere -- so ADR-049's deferral reason (b) is
a fallback case, not a blocker, and the existing uniform fallback covers
it. Reason (a), actor normalisation, is partly vindicated: dyad_new_id
is always populated, but 122 ids carry more than one name, 75 names
carry more than one id, and 15,020 events, 3.4%, change dyad_new_id
between store versions.

Found while measuring, recorded not fixed: ADR-049 section 8 is only
partly implemented. proportional attaches all four required per-row
provenance annotations and none of them reach the output parquet. The
ledger counters are present and correct; the row annotations are
computed and dropped at the write. That is the same defect #533 asks us
to close, and the third recorded instance of the pattern after the
legacy GedLoader's is_summary flag and event precision at cell-month
aggregation, so it should be fixed once as that issue's deliverable
rather than incidentally here.

Also added, from views-models' measurement: a sixth evaluation metric.
One cell is 38.3% of summed CRPS across ten baseline arms, yet excluding
it leaves the ordering byte-identical -- because no arm can draw near
113,395, so it enters every arm as nearly the same additive constant.
What it does instead is compress the relative spread across arms from
6.5% to 4.0%. The harm is dynamic-range compression, not rank
distortion, which is both a weaker claim and a more tractable one. That
becomes the primary endpoint for remedy comparison, with earth-mover
distance demoted to diagnosing where the mass went. views-models also
raised a sound objection to my own recommendation of scale-invariant
scoring: their arms differ only in how magnitude is drawn, so
scale-invariance may attack the very axis the discrimination lives on.
Recorded as a caution with a pre-registered prediction rather than
quietly dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfeuULbPTqrx4qtNki5dy9
Simon's redirection: working tools, not a research agenda. Everything in
VIEWS is blocked on being able to distribute summary events in time and
space, so this is the foundation for that and the specification of the
rest.

THE DESIGN MOVE. Every allocation strategy is a weight function composed
with one shared rounder:

    a_e = LRM(v_e, w_e),  w_e(t,c) = tau_e(t) * sigma_e(c)

Conservation (ADR-040 Invariant 1) is then proved once, in the rounder,
rather than re-argued per strategy; a new allocation idea becomes a new
tau or sigma registered through the existing OCP seam with no builder
change; and the two axes compose by multiplication rather than by
sequential re-rounding, which is strictly less rounding loss than
ADR-049 section 4's temporal-then-spatial prescription.

apportionment.py implements largest-remainder (Hamilton) with five
properties, each proved in the docstring and tested: conservation,
boundedness, determinism, zero-preservation, order preservation.
Zero-preservation is the substantive one -- a month or cell with no
supporting evidence never receives mass -- and it is provable: the
shortfall equals a sum of fractional remainders each below one, so it
can never exceed the count of strictly positive remainders. This
replaces ceil_split's inflation by construction.

TWO DEFECTS FOUND AND FIXED.

The existing _distribute_value was not largest-remainder. It ranked by
weight rather than by fractional remainder, which conserves but is not
bounded: for value=10 over weights (0.5, 0.25, 0.25) it returned
(6, 2, 2) although the first cell's ideal share is exactly 5.0. It also
sent the remainder to the already-largest cell every time, which is the
concentration bias ADR-049 lists among its own negative consequences.
The change carries zero parity risk: production_parity selects
passthrough, which never reaches that function.

refresh_pipeline.sh step 3 ran build_viewpoint.py with no --profile, so
production's allocation policy was decided by an argparse default in
another file and a one-line edit elsewhere could have switched what
production emits, silently. The profile is now named explicitly, with a
comment stating why and naming kgi_distributed as the deliberately
not-invoked alternative. Zero behaviour change; production emits exactly
what it did before.

THE SPEC, 11_allocator_spec.md, names what remains: temporal weights T0
endpoint (parity), T1 uniform, T2 activity with a dyad -> conflict ->
side_b -> country -> uniform cascade; spatial weights S0 centroid
(parity), S1 uniform, S2 fatality, S3 declared support. Four
combinations to test, with uniform as the floor any weighting must beat,
and a three-tier test plan with the surprises pre-named.

A THIRD DECLARED SIGNAL, previously unused anywhere in this repo or the
legacy loader. event_clarity == 2 is UCDP stating that the reported
count aggregates several incidents, and it covers 45.56% of all fatality
mass -- more than where_prec and date_prec combined. Crossed with
precision it separates the artefact class from real concentration:
imprecise-and-aggregated is 36.41% of mass and includes all four worked
examples, while precise-and-aggregated is 9.17% and includes Murambi,
40,000 deaths at one memorial site with the location known exactly. That
is real and must never be redistributed. Magnitude cannot tell the two
apart; this field can, which is why views-hydranet and views-models were
both right to refuse winsorising. It belongs to the declaration, not to
the allocator -- allocation is triggered by imprecision alone, and the
existing precision gates already handle that correctly.

ALSO FOUND, recorded not acted on. ADR-049 takes the target polygon from
the centroid's admin-1, but the events say otherwise: the Tigray event's
where_description is "Tigray, Afar, Amhara", three regions, and we
spread it over one. 20.7% of KGI mass has a comma-separated
where_description. If the true support is roughly three times wider than
the polygon we use, that is the size of the residual measured earlier --
cell 148759 fell 7x all-history while the largest cell at month 504
stayed at 41,645. So the support may be wrong, not just the weights, and
weight comparisons would be measuring the wrong thing. Added as S3 and
scoped as a hypothesis to test on top-mass events by hand rather than as
a general free-text parser.

CORRECTIONS TO MY OWN EARLIER CLAIMS, all from measurement.

Denton (1971) does not reject T1 uniform. His objection is to the
identity-matrix penalty when adjusting an existing sub-annual series; with
no indicator series his first-difference penalty reduces to the uniform
allocation. T1 is the correct no-indicator baseline and T2 is the
Chow-Lin case where that literature applies.

The detection critique is withdrawn. I argued the magnitude condition
best >= span was indefensible; measured, it excludes 2,382 events
carrying 2,560 fatalities, 0.06% of mass, median one death over three
months, and on the sources ceil_split actually runs on it is 375 events
and 209 fatalities. The condition is protecting against the inflation
ceil would otherwise cause.

A test name lied. test_remainder_to_highest_weight passed under both
rounding rules because weight and remainder coincide in its case;
renamed, with a discriminating case added.

MUTATION DRILLS. Seven mutations across the new guards. Six caught
immediately. One survived: removing the key tie-break broke nothing,
because sorted is stable and the determinism test had no tied leader.
Fixed with 11 over two equal weights, and now caught.

ruff and mypy clean (90 source files); 2,473 passed, 16 skipped, 17
xfailed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfeuULbPTqrx4qtNki5dy9
The working tool. A researcher blocked on summary events can now run:

    uv run python scripts/build_viewpoint.py \
        --profile kgi_kti_distributed \
        --output data/viewpoint/kgi_kti_distributed.parquet

uniform_span is option T1 from the allocator spec: spread an event UCDP
declares temporally imprecise across the months it spans, conserving
exactly. It triggers on the DECLARATION -- date_prec >= 4 and a span
crossing a month boundary -- not on span and magnitude. Both conditions
are needed: date_prec >= 4 within one month needs no allocation, and a
span over several months with date_prec <= 3 is a precisely dated event
straddling a calendar boundary, which is a different problem.

Allocation goes through the shared largest-remainder rounder, so two
things follow by construction rather than by argument. It cannot inflate,
where ceil_split assigns ceil(v/span) to every month and overshoots by a
measured 0.91% across the store. And one death over three months stays
one death in one row, because rows are emitted only where the allocation
is nonzero, bounding output at min(v, span) instead of span.

kgi_kti_distributed differs from production_parity in four fields, and
the load-bearing one is dropping source_distribution_map. Both
production_parity and kgi_distributed route annual-source events to
date_end_only, reproducing the legacy annual loader's
fix_summary_events=False, and that override is the single reason the
Tigray war's whole-year aggregate lands entirely on December 2021.

MEASURED over the full store, 128s, 2,815,165 rows:

  cell 148759 / month 504 / sb      113,395  ->   1,180
  largest sb cell at month 504      113,395  ->   3,470
  that cell's share of the month      98.1%  ->   28.0%
  cells with mass that month            248  ->     922
  event 463131                        1 row  ->  216 rows (12 x 18)
    best conserved                            113,368 exactly
    its own peak cell-month           113,368 ->   3,471

So the pre-registered surprise -- does a remedy move the spike rather
than shrink it -- is answered for both axes. The earlier
2.7x-not-removal figure was the SPATIAL axis alone; the temporal axis was
carrying most of the remaining concentration, which is what you would
expect of a whole-year aggregate.

Two cautions recorded rather than buried. Row count is 7.6x parity's and
downstream must absorb it. Total mass rises from 3,473,358 to 4,258,880,
which is not a conservation failure but the effect of
exclude_where_prec=() readmitting the where_prec 4 and 6 events parity
drops; those two changes are separable and should be reported separately.

AN UNPREDICTED SURPRISE, and it is UCDP's rather than ours. high < best
for 3,942 events carrying 510,908 fatalities, and it concentrates exactly
where we care: 0.89% of all events, 2.81% of KGI, 5.36% of
event_clarity==2, with 86% of the violating mass in KGI events. Event
463131 reads best 113,368, low 113,368, high 77,825. Attributed upstream
because the same three values appear in every version of the event in the
store and 90.7% of affected ids violate it in all their versions, so our
consolidation is not mixing fields across versions. Also: low == best for
93.4% of events and high == best for 84.9%, so the interval is degenerate
for the large majority.

Consequence: any consumer treating [low, high] as an uncertainty interval
gets a reversed interval for about 3% of KGI events, weighted toward the
largest. The allocator conserves each field independently and so
propagates the inconsistency rather than hiding it, which is correct, but
it has to be declared rather than discovered downstream. Not fixed here;
silently repairing source data would be worse than carrying it.

Mutation drill on uniform_span: ceil instead of the rounder, dropping the
span guard, emitting zero rows, conserving only best. All four caught.

production_parity is untouched and remains what refresh_pipeline.sh
invokes, explicitly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfeuULbPTqrx4qtNki5dy9
)

Option T2. The measurement came first: on 20,000 events whose true month
is known, with each event removed from its own indicator, activity
weighting cut the mean distance from the true month from 4.883 months to
3.305 and raised the share of mass landing in the right month from
0.0825 to 0.1525, beating uniform on 64.9% of events against 22.9%
worse. T2 needed a signature change across six registered strategies and
that churn is only earned if the extra assumption pays, so
scripts/evaluate_temporal_allocation.py ran before any of this was
written. The same script is what would falsify the choice.

THE SIGNATURE CHANGE, without breaking anything. The temporal registry
now carries a typed TemporalStrategy Protocol whose weight_map parameter
DEFAULTS to None. Every one of the six strategies gained the parameter
and ignores it unless it wants it, which is the arrangement
spatial_distribution already uses for passthrough. Result: 196 existing
tests pass unchanged. That mattered more than it looks -- these functions
are exported, the package is on PyPI, and 28 views-models requirement
files depend on it, so a breaking signature change would have rippled
outward for no benefit.

temporal_weights.py is the temporal mirror of spatial_weights.py. The
indicator pool is deliberately strict: where_prec <= 2 AND date_prec <=
2. where_prec == 3 is an admin-2 centroid assignment, precisely coded
rather than precisely known, and using it would build the indicator
partly out of the artefact the allocator exists to undo.

activity_weighted shares uniform_span's trigger and rounder; only the
weights differ. The cascade runs dyad, conflict, side_b, country and
takes the first level with nonzero activity across the span -- dyad
carries 98.6% of cases. With no map or no activity anywhere it falls back
to uniform_span, never to date_end_only, because that is the behaviour
being replaced.

MEASURED, full-store builds to scratch:

                              parity      uniform     activity
  cell 148759 / 504 / sb     113,395        1,180          240
  month 504 largest cell     113,395        3,470          642
  that cell's share            98.1%        28.0%        13.6%
  month 504 slice ESS           1.04         9.05        20.24
  rows                       372,303    2,815,165    2,654,111
  build time                       -        128 s        426 s

The effective sample size of that month's state-based slice goes from
1.04 to 20.24, a 19x improvement on views-hydranet's own instrument and
the thing #533 is actually about. Under parity the score for that slice
IS one number; now it is twenty cells' worth.

A RESULT NOT TO OVERSELL. The Tigray event's own peak cell-month RISES,
3,471 under uniform to 11,732 under activity. That is the mechanism
working. Uniform spreads flat across twelve months; activity concentrates
into the months with observed fighting, so the event's internal peak goes
up, while the cell-month total at 148759/504 goes down because December
2021 had low observed activity relative to the rest of the span. So
activity weighting moves mass to where evidence says it belongs,
sometimes raising a peak and sometimes lowering one. Net effect on
concentration is strongly positive; monotonic peak reduction is not what
it does and should not be claimed.

THE LIMITS, pinned in tests rather than left in prose. The gain is in the
mean, not the median -- median hit share is 0.00 for uniform and activity
alike, because most events are small enough to land wholly in one month
and usually the wrong one, so this helps the typical fatality rather than
the typical event. And a zero-activity month receives zero, by the
rounder's zero-preservation property, which says "no observed fighting,
so invent none". That is the assumption most likely to be wrong: absence
of well-located reporting is not absence of fighting, and 1,760 KTI
events have at least one zero-activity month inside their span. A test
pins it so changing it has to be a decision.

Two incidental confirmations. Conservation held across both new
allocators, total best identical at 4,258,880. And activity emits FEWER
rows than uniform, 2,654,111 against 2,815,165, because zero-activity
months are not emitted -- zero-preservation pays for itself in output
size as well as in honesty.

Cost: activity is 3.3x slower to build, from the indicator scan and the
per-event cascade. Inside a monthly budget, but worth knowing before
anyone runs it twenty times for a multiple-imputation ensemble.

Mutation drill on activity_weighted: falling back to date_end_only,
reversing the cascade, ignoring activity, dropping the span guard. All
four caught.

ruff and mypy clean (91 source files); 2,509 passed, 16 skipped, 17
xfailed, 0 failed. production_parity untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfeuULbPTqrx4qtNki5dy9
@Polichinel
Polichinel merged commit edba360 into development Nov 6, 2026
6 checks passed
@Polichinel
Polichinel deleted the docs/533-adr049-profile-and-observers branch November 6, 2026 14:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant