Skip to content

processing.completeness

Canonical completeness and model-usability logic.

Two kinds of flag roll up from two atomic facts -- whether each day was surveyed (days.survey_complete, which the project cleaner sets from the day's surveyed trips or a declared no-travel day), and each tour's structural validity:

  • survey_complete -- was it reported? Rolls up from complete days. One answer per run, never narrowed by model criteria and never configurable: if it is wrong, the fix belongs upstream in the project cleaner.
  • one column per usability profile -- can the model consume it? Fuses each tour's structure with its household-day coherence, then gates the rest.

A profile states a standard on four axes -- which home has to close a tour, what the household-date has to show, which zone system has to be able to address the record, and what a joint tour has to be -- and a run may stamp several, so different consumers can hold different standards. A joint-tour model needs whole households and fully joint tours; a trip-level estimation does not, and needs no model geography at all.

Every profile is named in config and answers every axis, so a column's meaning reads off the config without knowing a base rule, and no verdict appears that nobody asked for. Consumers name the profiles they read (a formatter's usability_profile, the weighting's weight_profiles), and survey_complete is always available as the floor beneath all of them.

Each flag comes from one place in the tree -- its direction -- via a counting operation (the op column below). Naming the direction makes it clear that household-day and the joint entities are the same kind of thing: cross-person groupings, not parent/child.

direction value comes from example
self measured on the record a trip never filled out
up aggregate your own children person with zero complete days
down inherit your parent's verdict good trip on a dropped tour
lateral aggregate a cross-person group one member skips a day, whole date fails

The flow, and the rule on each line:

COMPLETE -- rolls up from complete days     op        rule
------------------------------------------------------------------------
person-day ...........................   direct    days.survey_complete, from the cleaner
                                                    (all trips surveyed, or no-travel)
 ├ trip / tour .......................   inherit   its own value AND its day's
 ├ person ............................   >=1       has >=1 complete day
 â”” household-day .....................   ALL       all surveyable members complete that date
    â”” household ......................   >=1       has >=1 complete household-day

USABLE, UP (_cascade_up) -- counts from the tour fuse   op   rule
------------------------------------------------------------------------
household ...........................   >=1       has >=1 usable household-day
 ├ household-day ....................   ALL       all surveyable members' days usable that date
 â”” person ...........................   >=1       has >=1 usable day
    â”” day ...........................   >=1       >=1 usable tour (or a no-travel day)
       â”” tour .......................   fuse      complete AND admitted-quality AND
                                                     hh-day complete AND has a zone
                                                     (the last three are the axes)

USABLE, DOWN (_cascade_down) -- inherits from the household   op   rule
------------------------------------------------------------------------
household -> person -> day -> tour ..   inherit   unusable if the one it belongs to is
 â”” linked trip / unlinked trip ......   inherit   takes its tour's verdict

JOINT (flag_joint) -- judged beside the chain, after it     op   rule
------------------------------------------------------------------------
joint tour ..........................   >=2       >=2 tours on it, and its
                                                  household usable
joint trip ..........................   >=2       >=2 linked trips on it, likewise;
                                                  judged again after rejected joint
                                                  tours void their ids

JOINT IDS -- joint_tour_id_<profile> on tours and both trip tables, and
joint_trip_id_<profile> on linked trips: the bare id where the record is on it,
null where it is not. Decided first within flag_joint, read by the groupings
above, the weighting and the formatters; the bare id is the survey's own
answer and is never edited.
------------------------------------------------------------------------
partial .............................   direct    usable and in a survey-detected group
fully_joint .........................   class     usable, not work/school/escort, and
                                                  made the same joint trips as the
                                                  others (largest class of identical
                                                  legs); then the tour needs >=2 such
                                                  people and one purpose, or everyone
                                                  on it travels alone

declared no-travel: num_reasons_no_travel >= 1, OR proxy_complete (a proxy filled the day in --
  this is how children, who file no trips themselves, still get a complete day).
surveyable: persons whose travel the survey could collect at all. Unsurveyable persons
  (unrelated members, e.g. roommates) have no day rows in the vendor data; where a source
  carries any, they neither veto the household-day ALL reductions nor inherit their verdict.
VALID feeds the fuse: trips -> linked trips -> tour, home-to-home, no missing legs.
op: ALL / >=1 / >=2 = quantity gate (count members vs threshold);
    direct = measured; inherit = take a neighbour's verdict; fuse = AND of conditions

Because each level reads its neighbours, the derivation order is load-bearing; getting it wrong fails loudly (an unflagged member table raises), never silently.

This package is the one place the logic lives. The cascade_completeness pipeline step runs :func:cascade_complete once and then :func:stamp_usable per profile; every downstream consumer only reads the resulting flags.

Modules:

  • profiles: what a profile is, and how config states one.
  • survey_complete: the reporting cascade, once per run.
  • household_day: the household-date reduction, for both kinds of flag.
  • zone_coverage: whether a record's locations have a zone.
  • usable_tours: the tour fuse, where a verdict is decided.
  • usable_cascade: that verdict, walked up and then down every table.
  • joint_groupings: which records are on a joint tour or joint trip, per profile.
  • kept_records: which records survive a verdict once references are restored.
  • descriptions: what each stamped column means, in words.
  • cascade_completeness: the pipeline step.

processing.completeness.cascade_completeness

The pipeline step: reporting completeness once, then one pass per profile.

cascade_completeness

cascade_completeness(
    households: pl.DataFrame | None = None,
    persons: pl.DataFrame | None = None,
    days: pl.DataFrame | None = None,
    unlinked_trips: pl.DataFrame | None = None,
    linked_trips: pl.DataFrame | None = None,
    joint_trips: pl.DataFrame | None = None,
    tours: pl.DataFrame | None = None,
    joint_tours: pl.DataFrame | None = None,
    usability_profiles: dict[str, dict[str, str]] | None = None,
    canonical_data: CanonicalData | None = None,
) -> dict[str, pl.DataFrame]

Cascade survey_complete through the hierarchy and stamp one column per profile.

Walks both kinds of flag across every table so they are internally consistent with whatever was set upstream (the vendor's day-level completeness, any manual adjustments in the project cleaner, and the structural verdicts from tour extraction):

  • survey_complete -- survey reporting completeness, cascaded hh <-> person <-> day <-> trip/tour. Never narrowed by model criteria, and never configurable: if it is wrong the fix belongs upstream in the cleaner.
  • one column per usability profile -- the subset of survey_complete that profile admits.

A profile states its standard on four axes: which home has to close a tour, what the household-date has to show, which zone system must hold every location, and what a joint tour has to be. Several can be stamped in one run so different consumers can hold different standards -- a joint-tour model needs whole households and fully joint tours, a trip-level estimation does not:

usability_profiles:
  ctramp:
    tour_closes_at: primary_home
    household_day_needs: all_members
    zone_coverage: taz
    joint_tours: fully_joint
  analysis:
    tour_closes_at: anywhere
    household_day_needs: nothing
    zone_coverage: none
    joint_tours: partial

Nothing is implicit: every column stamped is named in config and every profile answers every axis. Consumers then choose which to honour by name (weight_profiles in the weighting, usability_profile in each formatter), and none of them re-derive completeness or tour validity -- the formatters read the column and raise if it is absent. survey_complete is always available as the floor without being declared.

See stamp_usable for the per-level rules and parse_usability_profiles for the vocabulary.

Parameters:

Name Type Description Default
households pl.DataFrame | None

Canonical households.

None
persons pl.DataFrame | None

Canonical persons.

None
days pl.DataFrame | None

Canonical person-days.

None
unlinked_trips pl.DataFrame | None

Canonical unlinked trips.

None
linked_trips pl.DataFrame | None

Canonical linked trips.

None
joint_trips pl.DataFrame | None

Aggregated joint trips.

None
tours pl.DataFrame | None

Canonical tours (with tour_data_quality / tour_category).

None
joint_tours pl.DataFrame | None

Aggregated joint tours.

None
usability_profiles dict[str, dict[str, str]] | None

Profile name -> {tour_closes_at, household_day_needs, zone_coverage, joint_tours}. Required, and every profile answers every axis: there is no default at either level, so a verdict never appears unasked and a column's meaning is never implicit.

None
canonical_data CanonicalData | None

Injected by the pipeline. Profile columns are named from config and so cannot be model fields; they are registered here with a description of what each admits, which keeps them in the delivered output and documented rather than silently dropped.

None

Returns:

Type Description
dict[str, pl.DataFrame]

The provided tables, each with survey_complete reconciled and one column

dict[str, pl.DataFrame]

per profile added.

Raises:

Type Description
ValueError

If usability_profiles is missing or malformed.

processing.completeness.descriptions

What each stamped column means, in words -- for the log and for the writer.

A profile's columns are named in config, so they cannot be model fields. They are described here instead: once into the run log, so an analyst can see what a gate admitted, and once onto the canonical data, so the delivered output carries the same sentence.

suggest_usability_columns

suggest_usability_columns(frame: pl.DataFrame) -> str

A "did you mean" line naming the boolean columns frame actually has.

For a consumer that was pointed at a usability column which is not there. The names come from config, so there is no pattern to match on: a rule like "ends in _usable" only holds until a project picks a name that does not fit it, and then the hint reports none rather than admitting it cannot tell. Every boolean column is offered instead. Some will be unrelated, which costs a reader one glance; a confident empty answer costs them a debugging session in the wrong step.

processing.completeness.household_day

The household-date reduction, for both kinds of flag.

A household-day is the set of person-days sharing one hh_id and travel_date. Both reductions here are the same shape -- ALL surveyable members' days pass, written back onto every day on that date -- and they sit side by side so the usable one reads as the mirror of the complete one.

flag_household_day_complete

flag_household_day_complete(
    tables: dict[str, pl.DataFrame | None],
) -> None

Stamp hh_day_survey_complete on the days table, in place (reverse cascade).

A household-day -- the set of person-days sharing one hh_id and travel_date -- is complete only when every surveyable member's day is complete (an ALL reduction over surveyable member-days). The result is written back onto each day on that date, so hh_day_survey_complete marks whether the day belongs to a coherently observed household-date.

Unsurveyable members (see :func:_join_surveyable) neither veto the date nor borrow its verdict: their own day rows -- if a data source carries any -- keep their own survey_complete, which is normally False since they file no trips. A source that nonetheless marks one complete is believed rather than overruled: the contradiction is upstream, and inventing a verdict here would hide it.

This runs after :func:rollup_completeness, so survey_complete already reflects ancestry; the reduction then flows the other way, up from members to the shared date. Idempotent. Days without hh_id / travel_date (e.g. schema-only fixtures) fall back to each day's own survey_complete.

flag_household_day_usable

flag_household_day_usable(
    tables: dict[str, pl.DataFrame | None], cols: UsabilityProfile
) -> None

Stamp the household-day usable flag: ALL surveyable member-days usable, in place.

The usable-side mirror of :func:flag_household_day_complete: a household-day is usable only when every surveyable member's day is model-usable, not merely complete. household then needs at least one such date. Unsurveyable members neither veto the date nor inherit its verdict. Requires the per-record flag on days; days without hh_id / travel_date fall back to each day's own verdict.

processing.completeness.joint_groupings

Which tours and trips are on a joint tour or joint trip, per profile.

joint_tour_id and joint_trip_id record the groups the survey detected, once, for every profile. Whether a record is on its group is a profile's call, so each profile gets its own copy of the id -- joint_tour_id_<profile> on tours and both trip tables, joint_trip_id_<profile> on linked trips -- holding the same value where the record is on it and null where it is not. Nothing here edits the bare id.

Runs after the up and down walks in usable_cascade, since it reads every table's verdict. The functions below are in the order they run.

MIN_JOINT_PARTICIPANTS module-attribute

MIN_JOINT_PARTICIPANTS = 2

flag_joint

flag_joint(
    tables: dict[str, pl.DataFrame | None], cols: UsabilityProfile
) -> None

Decide the joint tours and joint trips for cols, in place.

First which tour is on which joint tour, and which trip on which joint trip. Then the groupings are judged and the ids of any rejected grouping voided -- twice, in the only order the chain runs: rejecting a joint tour takes its tours' trips off their joint trips, which can empty a joint trip, so joint trips are judged once more. Nothing flows back up, which the final check states rather than assumes.

Raises:

Type Description
RuntimeError

If the second void changed anything, meaning a rule now flows from joint trips back to joint tours.

processing.completeness.kept_records

Which records a usability profile admits, read off every table at once.

Nothing here removes a record from the canonical data. keep_records returns filtered copies of the tables it is given -- the rows whose verdict column is true -- and leaves the inputs as they were. Only a formatter ever writes fewer records than the survey holds, and it writes exactly these.

Filtering each table on its own verdict is enough because the cascade already made the verdicts agree: it carries the household's verdict down to every record in it, voids the joint ids of any grouping it rejects, and dissolves any grouping left with one record. What this module adds is the proof. After filtering it follows every reference the schema declares and raises on the first one that no longer resolves, so a cascade that stops agreeing with itself fails here, loudly, rather than being quietly patched.

The formatters gate on this, and the weighting runs it as a check: weight on a record the filter excludes is weight the output would lose.

GATED_TABLES module-attribute

GATED_TABLES = (
    "households",
    "persons",
    "days",
    "tours",
    "linked_trips",
    "unlinked_trips",
    "joint_trips",
    "joint_tours",
)

KeepLedger dataclass

How many rows each table lost to the profile's verdict.

excluded class-attribute instance-attribute

excluded: dict[str, int] = field(default_factory=dict)

add

add(bucket: dict[str, int], key: str, n: int) -> None

Add n to key in bucket.

keep_records

keep_records(
    tables: dict[str, pl.DataFrame], usability_flag_col: str
) -> tuple[dict[str, pl.DataFrame], KeepLedger]

Keep the records usability_flag_col admits, and prove nothing dangles.

Parameters:

Name Type Description Default
tables dict[str, pl.DataFrame]

Canonical tables, keyed by name, carrying the profile's joint ids under the bare names (the formatter resolves them first).

required
usability_flag_col str

The stamped verdict to gate on.

required

Returns:

Type Description
dict[str, pl.DataFrame]

The kept tables, and a ledger of what each lost. Tables absent from the

KeepLedger

input stay absent.

Raises:

Type Description
ValueError

If a gated table carries no verdict, which means cascade_completeness did not run or stamped a different profile; or if the kept records do not agree with one another, which means the cascade did.

select_profile_columns

select_profile_columns(
    tables: dict[str, pl.DataFrame],
    usability_profile: str,
    *,
    only: tuple[str, ...] | None = None
) -> dict[str, pl.DataFrame]

Resolve this consumer's per-profile columns to their base names.

The weighting writes one weight column per profile, and the cascade one copy of each joint id per profile, each suffixed with the profile's name. A consumer already names the profile it reads, and the suffix is that name, so one setting answers every question and there is no second one to disagree with it.

Renaming here rather than at each use is what keeps every downstream read -- sampleRate, hhexpfac, which tours are joint -- correct without knowing profiles exist at all.

Parameters:

Name Type Description Default
tables dict[str, pl.DataFrame]

Canonical tables, keyed by name.

required
usability_profile str

The profile this consumer reads.

required
only tuple[str, ...] | None

Base columns to resolve, for a caller that wants the joint ids but not the weights; every column a profile writes, by default.

None

Returns:

Type Description
dict[str, pl.DataFrame]

The same mapping with this profile's columns under the base names. A

dict[str, pl.DataFrame]

table with no suffixed column at all is returned untouched, which is the

dict[str, pl.DataFrame]

single-weight-set case.

Raises:

Type Description
ValueError

If a table carries suffixed columns but none for this profile. The bare column would then be read instead -- whatever it happens to hold -- and a formatter would publish another universe's expansion factors or joint tours.

check_kept_records_agree

check_kept_records_agree(
    tables: dict[str, pl.DataFrame], usability_flag_col: str
) -> None

Raise if the kept records do not form a consistent set.

Two things, each once a repair this module made silently:

  1. Every reference the schema declares resolves. A required one that does not is a record whose household, person or day the profile rejected; an optional one is a joint id the profile should have voided.
  2. No joint id is carried by fewer than two records.

Each is something the cascade guarantees under every profile, so a failure names a cascade bug. A rule that holds under some profiles only -- such as every leg of a joint tour being a joint trip, which partial does not promise -- belongs to the consumer that needs it, since this function receives a column name and cannot know the profile's settings.

Parameters:

Name Type Description Default
tables dict[str, pl.DataFrame]

The kept tables.

required
usability_flag_col str

The verdict they were gated on, for the message.

required

Raises:

Type Description
ValueError

On the first check that fails.

processing.completeness.profiles

What a usability profile is, and how config states one.

A profile answers four axes explicitly -- which home has to close a tour, what the household-date has to show, which zone system has to hold every one of the record's locations, and what a joint tour has to be. Nothing here touches a table; it is the vocabulary the rest of the package is written against.

NO_ZONE_COVERAGE module-attribute

NO_ZONE_COVERAGE = 'none'

USABLE_FAMILY module-attribute

USABLE_FAMILY = 'usable'

SURVEY_COMPLETE module-attribute

SURVEY_COMPLETE = 'survey_complete'

TourClosesAt

Which home has to close a tour.

The quality codes divide into other-home anchors, open ends and missing data, and this axis walks down the first two. No setting admits missing data.

PRIMARY_HOME class-attribute instance-attribute

PRIMARY_HOME = 'primary_home'

ANY_HOME class-attribute instance-attribute

ANY_HOME = 'any_home'

ANYWHERE class-attribute instance-attribute

ANYWHERE = 'anywhere'

HouseholdDayNeeds

What the household-date has to show.

ALL_MEMBERS class-attribute instance-attribute

ALL_MEMBERS = 'all_members'

NOTHING class-attribute instance-attribute

NOTHING = 'nothing'

JointTours

What a joint tour has to be.

Activity-based models draw a joint tour's trips once for everyone on it, so a person who leaves for a leg is not on the same tour; the survey's own groups need no such thing.

FULLY_JOINT class-attribute instance-attribute

FULLY_JOINT = 'fully_joint'

PARTIAL class-attribute instance-attribute

PARTIAL = 'partial'

UsabilityProfile dataclass

One usability standard, stated on every axis, and the columns it writes.

A profile says which home has to close a tour, what the household-date has to show, which zone system has to be able to address the record, and what a joint tour has to be. Config gives all four explicitly; none may be left implicit there.

Coverage is an axis rather than a universal because the zone systems differ: a trip outside one model's area may sit comfortably inside another's, so "has a zone" is a question only a named consumer can answer.

Two things hold at every setting and so are not axes. A profile is always a subset of survey_complete -- a usability column admitting unreported records would be a different flag wearing the name. And no profile admits a tour with missing data (a missing leg, an activity that never happened), as opposed to one that merely ends somewhere unexpected.

Two columns come out of a pass. flag is the per-record verdict consumers read. household_day records, per date, whether all surveyable members' days passed. It is computed for every profile even where that profile does not gate on it, so the column means the same thing everywhere and can still answer "which dates cost this household its usability".

name instance-attribute

name: str

tour_closes_at instance-attribute

tour_closes_at: TourClosesAt

household_day_needs instance-attribute

household_day_needs: HouseholdDayNeeds

zone_coverage class-attribute instance-attribute

zone_coverage: str = NO_ZONE_COVERAGE

joint_tours class-attribute instance-attribute

joint_tours: JointTours = JointTours.PARTIAL

flag property

flag: str

Per-record verdict column.

requires_zones property

requires_zones: bool

Whether this profile asks that a record's locations have a zone.

household_day property

household_day: str

All-surveyable-members-that-date column.

joint_tour_id property

joint_tour_id: str

joint_tour_id as this profile sees it: the id where the tour is on it, else null.

joint_trip_id property

joint_trip_id: str

joint_trip_id as this profile sees it: the id where the trip is on it, else null.

admitted_quality property

admitted_quality: tuple[TourDataQuality, ...]

Tour quality codes this profile's closure setting admits.

needs_whole_household_day property

needs_whole_household_day: bool

Whether a date must show every surveyable member's day complete.

requires_fully_joint property

requires_fully_joint: bool

Whether everyone on a joint tour must share every leg of it.

usable_col_for

usable_col_for(profile: str) -> str

The column carrying profile's verdict.

Parameters:

Name Type Description Default
profile str

A usability profile named in config, or :data:SURVEY_COMPLETE to mean the whole valid survey.

required

Returns:

Type Description
str

usable_<profile>, or :data:SURVEY_COMPLETE unchanged -- that column

str

is vendor data rather than a verdict this pipeline reached, so it is

str

available under its own name but not a member of the family.

profile_of

profile_of(usability_flag_col: str) -> str | None

The profile a verdict column belongs to, or None for one outside the family.

The inverse of :func:usable_col_for: usable_ctramp -> ctramp. survey_complete, or a hand-written flag, names no profile, so a reader looking for that profile's other columns (joint_tour_id_<profile>) gets None and falls back to the bare column.

parse_usability_profiles

parse_usability_profiles(
    spec: dict[str, dict[str, str]],
) -> list[UsabilityProfile]

Turn the configured profile block into profiles, or say why it cannot.

Every profile answers every axis. A missing axis is an error rather than a default, because a default is exactly the implicit meaning profiles exist to remove: the config should say what a column means without the reader knowing a base rule.

Parameters:

Name Type Description Default
spec dict[str, dict[str, str]]

Mapping of profile name to {axis: value}.

required

Returns:

Type Description
list[UsabilityProfile]

One profile per entry, in declaration order.

Raises:

Type Description
ValueError

If the block is empty, a name collides with a column the reporting cascade owns, or any axis is missing, unknown, or given a value outside its vocabulary.

processing.completeness.survey_complete

The reporting cascade: what the survey collected, walked across the tables.

Survey reporting only. Nothing here consults a model criterion or reads a profile, and one run of the pipeline has exactly one answer, so this is independent of how many usability passes follow it.

rollup_completeness

rollup_completeness(tables: dict[str, pl.DataFrame | None]) -> None

Roll survey_complete UP from days, then broadcast each day's value down, in place.

Completeness is measured at the person-day (days.survey_complete is set upstream by the project cleaner from surveyed trips / a declared no-travel day). This derives the rest:

  • persons.survey_complete = it has at least one complete day (an ANY rollup).
  • every day-record (trip, tour) = its own reporting AND its day complete -- broadcast down, since a trip is no more complete than the day it sits in. Joint trips and tours are not day-records; see _DAY_RECORDS.

households.survey_complete is a household-day rollup and is set separately by :func:rollup_household_complete (it needs hh_day_survey_complete first). Tables lacking survey_complete or the join key are left unchanged. Idempotent.

rollup_household_complete

rollup_household_complete(
    tables: dict[str, pl.DataFrame | None],
) -> None

Set households.survey_complete = has at least one complete household-day, in place.

A household is complete when at least one date was coherently observed (every member complete). Requires hh_day_survey_complete on days (from :func:flag_household_day_complete). Left unchanged if days or the flag is absent.

cascade_complete

cascade_complete(tables: dict[str, pl.DataFrame | None]) -> None

Derive every survey_complete flag, in place.

Survey reporting only: what the vendor collected, cascaded through the hierarchy. Nothing here consults a model criterion, and one run of the pipeline has exactly one answer, so this is independent of how many usability passes follow.

Parameters:

Name Type Description Default
tables dict[str, pl.DataFrame | None]

Mutable dict of table_name -> DataFrame (or None).

required

processing.completeness.usable_cascade

One profile's verdict, walked across every table.

Reads the survey_complete flags the reporting cascade left behind and the tour fuse's verdict, then walks the hierarchy twice -- up, counting from the tour to the household, and down, inheriting from the household to the trips -- and last decides the joint groupings beside it (see joint_groupings). The functions below are in the order they run.

stamp_usable

stamp_usable(
    tables: dict[str, pl.DataFrame | None], cols: UsabilityProfile
) -> None

Derive one usability verdict for every table, in place.

Reads the survey_complete flags :func:cascade_complete left behind and writes the columns cols names. It never writes survey_complete, so it can be run more than once over the same tables to stamp several verdicts side by side, each under its own name.

Three passes, in the order the package docstring draws them: count up from the tour fuse to the household, inherit down from the household to the trips, then decide the joint groupings beside them.

Parameters:

Name Type Description Default
tables dict[str, pl.DataFrame | None]

Mutable dict of table_name -> DataFrame (or None).

required
cols UsabilityProfile

Column names this pass writes.

required

compute_usability

compute_usability(
    tables: dict[str, pl.DataFrame | None],
    profile: UsabilityProfile,
) -> None

Stamp survey_complete and one profile's verdict on every table, in place.

The two halves in order: reporting completeness once, then one usability pass over it. A run stamping several profiles calls :func:cascade_complete once and :func:stamp_usable per profile instead.

Parameters:

Name Type Description Default
tables dict[str, pl.DataFrame | None]

Mutable dict of table_name -> DataFrame (or None).

required
profile UsabilityProfile

The standard to apply, named on every axis.

required

processing.completeness.usable_tours

The tour fuse: the one place a usability verdict is decided rather than read.

Every other level counts or inherits. A tour is where reporting completeness, an admissible structure, household-date coherence and zone coverage are ANDed into a single verdict, and the rest of the cascade flows from it.

processing.completeness.zone_coverage

Whether a record's locations have a zone in the geography a profile names.

The zone step joins each location to a zone polygon and leaves it null when no polygon contains it. This asks the one question that follows: does every location this record needs have a zone in the named geography?

That is a fact about where the zone layer stops, not about how well the record was reported. A trip to a place outside the layer may be reported perfectly; a consumer that writes zone IDs simply has nothing to write for it.