Chimmani
W-08Independent build · Public OSHA data · Case study

Designing a safety ontology on public OSHA data

I designed an ontology in the style of Palantir Foundry over OSHA's public injury filings: seven object types, seven link types, four governed analyst actions with an audit log, and five functions that call a tested analysis pipeline instead of re-implementing it. This page explains each design choice and what the build does and does not show.

Ontology designFoundry conceptsPythonOSHA ITAGovernance
At a glance · the 20-second version

A pipeline gives an EHS team a table. An ontology gives them the site, its filings, the rule that fired, the right peers, and a governed way to record a judgement that changes the numbers and leaves a trail.

7object types, each tied to a decision an EHS team makes
Role
Designer and author: spec, runtime, actions, functions, tests and documentation
When
October 2026
Stack
Python, pandas, a JSON ontology spec, SQLite and JSON export
Status
Merged to main of ehs-osha-analysis; demonstrated on a synthetic fixture
  1. Problem

    The pipeline already shows that a small share of implausible filings can move the national injury rate by a large factor. An EHS team needs to act on that per site and per filing, not read it from a summary table.

  2. What I did

    Modelled the data as linked object types, made analyst judgement a set of validated actions with an append-only log, and wired every calculation back to the pipeline code that the tests already check.

  3. Status

    Runs on a synthetic fixture linked to real reference objects. The path for the real filings uses the same loader but has not been run, because the raw files are not in the repository.

  4. Why it matters

    A benchmark built from public injury records is only as good as the governance over which records count. This design keeps that governance explicit, reviewable and reversible.

Step 1 · Problem

The decisions an EHS team needs to make

My public analysis of OSHA's Injury Tracking Application data covers 2,801,064 establishment filings from 2016 to 2024. It found that a small share of filings with implausible hours worked controls most of the national denominator. In 2019, filings that failed the plausibility screen held 99.6% of reported hours, and screening moved the aggregate total recordable incident rate (TRIR) from 0.016 to 4.018. A result like that is useful to a researcher as a table. An EHS team needs to work with it one site and one form at a time.

I started the design from the decisions, not from the columns. Each object type and link exists because one of these questions needs it:

  • Should this form count? A filing can fail a screen rule, look wrong to an analyst for a reason the screen does not catch, or be correct but not comparable to peers.
  • Which rule fired, and how much does that rule matter? Pooled across years, the rule for more than 4,500 hours per employee covers 96.65% of all reported hours. One rule can dominate a national figure.
  • How far does this year's headline rate depend on its worst filings? A year-level object puts each filing beside the aggregate it moves.
  • Which peers should this site be compared with? Peer groups are industry by employee-size band, and some cells are too small to publish.
  • Who decided to exclude a filing, when, and why? A judgement that changes a benchmark has to be recorded and reversible.
Step 2 · Object model

Object model

The model is a single JSON spec. Filing is the centre: it is the unit an analyst judges and the only object with editable properties. Everything else gives a filing its context: the site that filed it, the year it belongs to, the industry and peer cell it is compared with, and the screen rules that fired on it.

Object model of the safety ontology Filing sits at the centre and links many-to-one to Establishment, ReportingYear, Industry and PeerGroup, and many-to-many to QualityFlag. PeerGroup links to Industry, and Industry links to Sector. Filing carries the analyst writeback properties. establishment reporting_year quality_flags (m:n) peer_group industry industry sector Establishment establishment_id ReportingYear year Filing establishment_id-year writeback: flag, exclusion Sector NAICS 2-digit QualityFlag flag column PeerGroup naics3|size_band Industry NAICS 3-digit
Figure 1 The seven object types and seven link types in spec.json. Solid lines are many-to-one links backed by a foreign key. The dashed line is the one many-to-many link, backed by the filing's boolean flag columns. Every link can be followed in both directions.

Why each object type exists

Object typeBacked byWhy it exists
EstablishmentThe harmonised filings frame (latest filing per site)The unit an EHS manager is accountable for and the unit OSHA inspects. Company name lets a corporate team group its sites.
FilingThe harmonised frame after the pipeline's screen and peer keysAnswers "should this form count?". It holds the Form 300A case counts (columns G to L), the hours denominator, the six screen flags, the screen verdict as a read-only property, and the analyst writeback.
ReportingYearquality_by_year.csv and metrics_by_year.csvShows how far a year's headline rate depends on its worst filings, so a reviewer sees the filing and the aggregate it moves side by side.
Sectorquality_by_naics2.csvShows how exposed a parent NAICS sector is to implausible hours.
IndustryThe 209 NAICS 3-digit codes in peer_percentiles_pooled.csvThe industry level used for peer groups. It carries only the official NAICS 2022 title; codes that are not 2022 subsectors keep the bare code and are marked as having no official title, rather than getting an invented name.
PeerGrouppeer_percentiles_pooled.csv: 905 cells, 647 publishableAnswers "who are the right peers?". Size bands come from employee counts, not OSHA's size code, which changed meaning in 2023. Cells below the minimum size are kept and marked not publishable, so the model says why a comparison is unavailable.
QualityFlagThe pipeline's flag columns and quality_flag_prevalence.csvAnswers "which rule fired, and how much does it matter?". The no-injury contradiction flag is carried but marked as not counting toward the verdict, so it informs without excluding.

Source: docs/ontology.md · materialise.py

Design choices in the model

  • Every object carries a source property, so a reader can tell a synthetic object from one built from committed outputs.
  • The spec is checked when it loads, and every error is reported at once: keys must exist, primary keys cannot be null or editable, links must point at real types with matching key types, and actions may edit only properties marked editable.
  • Loading coerces each value to its declared type and rejects nulls in required properties and duplicate primary keys. A link integrity report counts resolved, null and dangling keys for every link.
  • The Filing to QualityFlag link is backed by the boolean flag columns the pipeline already writes, rather than a second join table that could drift from them.
Step 3 · Actions and audit

Actions and the audit log

The only way to change an object after it loads is to apply an action. Each action declares typed parameters, submission criteria that check the filing's current state, and the exact properties it edits. A rejected action changes nothing and writes nothing to the log.

ActionParametersSubmission criteriaEffect
flag_filing_implausiblefiling; coded reason; note, required when the reason is "other", at most 500 charactersNot already excluded by the screen; not already flaggedSets the analyst flag with its reason and note
clear_analyst_flagfiling; note of 10 to 500 characters, requiredThe filing is flaggedClears the flag and records why
exclude_from_benchmarkfiling; coded reason; note, required when the reason is "other"Not already excluded by the screen; not already excludedTakes the filing out of benchmark aggregates
restore_to_benchmarkfiling; note of 10 to 500 characters, requiredThe filing is excludedPuts the filing back and records why

Source: spec.json · runtime.py

Why analysts flag rather than delete

Deleting a suspect filing would make the benchmark look cleaner and make the decision invisible. Here an analyst's judgement is a property on the filing, set by a named action, with a coded reason. The filing stays in the data, its screen flags stay visible, and the decision can be reversed by another action that must also say why. Reasons are a fixed list, such as hours inconsistent with operations, company-wide hours reported, or employee count mistyped, so decisions can be counted and reviewed by type. Free text is only required where the coded reason is "other".

I kept two separate judgements because they mean different things. A flag says "this form is wrong". A benchmark exclusion says "this form may be right, but this site is not comparable with its peers". Both take the filing out of the aggregates, and both are recorded separately from the automated screen.

The screen verdict itself cannot be edited by any action. If a reviewer disagrees with the screen, the right change is to the screen's bounds, which is a method decision, not a record edit. The plausibility_screen function lets a reviewer try other bounds and see the verdicts without editing any object.

The audit log

Every applied action appends an entry with an id, the action type, the actor, a UTC timestamp, the parameters, and the before and after value of every property it edited. Any one filing's edit history can be read back. The log is exported with the objects, so the record of who changed what travels with the data it changed.

Step 4 · Functions

Functions that reuse the validated pipeline

The pipeline behind this ontology is already tested, including tests that check its published figures against the committed outputs. A second copy of a rate formula inside the ontology would be a second thing to get wrong. So each function is a thin adapter that calls the pipeline module the spec names under reuses, and the spec check fails if that module path does not import.

FunctionInputReusesWhat it returns
plausibility_screenSet of filings, optional boundsquality.apply_screenA what-if verdict per filing; edits nothing
filing_ratesOne filingload.derive_fieldsEstablishment TRIR and DART rate re-derived from the stored counts and hours
aggregate_trirSet of filingsquality.aggregate_trirRatio of sums over included filings
aggregate_ratesSet of filingsmetrics.compute_ratesTRIR, DART, LTIR and severity, screened and unscreened
peer_positionOne filingpeers.DEFAULT_PERCENTILESThe percentile band of the peer group the filing's TRIR falls in, or a status saying why there is no band: the filing is excluded, has no rate, or sits in a cell too small to publish

"Included" means the filing passes the screen and has neither an analyst flag nor a benchmark exclusion, so actions change the numbers through the same path as the screen. The tests assert two things. With no actions applied, aggregate_rates on the fixture equals the pipeline's own rate computation on the screened fixture, pooled and for each year. After a flag, the aggregate equals the pipeline's computation with that filing removed.

Source: functions.py · tests/test_ontology.py

Step 5 · Foundry map

How it maps to Foundry concepts

I built it in plain Python so it runs anywhere and can be checked by tests, but each part follows a Foundry Ontology concept, so the same design could be moved onto a Foundry ontology.

Foundry conceptIn this build
Object Typeobject_types in the spec: primary key, title property, typed properties with nullability and editability. Loading enforces the types and keys.
Link Typelink_types: cardinality, a foreign key or join rule, and a name for each direction.
Action Typeaction_types: parameters with required, conditional, allowed-value and length rules; submission criteria with failure messages; edits to editable properties only.
Functionfunctions.py: functions on one object or an object set, each delegating to a pipeline module.
Object SetObjectSet with filters and Search Around (following a link from a whole set), plus link traversal from a single object.
WritebackThe editable Filing properties for the analyst flag and benchmark exclusion, changed only through actions.
Action logAn append-only log of every applied action with before and after values, and per-object edit history.
Backing datasets and exportObjects are built from the harmonised frame and committed output tables. Export writes SQLite (one typed table per object type with an enforced primary key, the many-to-many link, the action log and the spec) and strict JSON.

Source: spec.py · runtime.py · export.py

Step 6 · Limits

What it shows and its limits

The build shows how I take a validated analysis and turn it into a model an EHS team can operate: objects named after the things they are accountable for, links that answer their questions, judgements that are governed and logged, and calculations that cannot drift from the tested method.

Synthetic demonstration. By default, Establishment and Filing objects come from a synthetic fixture. They link to real ReportingYear and PeerGroup objects so the joins are exercised, but no number computed from fixture objects is a finding. The figures quoted on this page come from the pipeline's committed outputs, not from the ontology demo.

Real-data path not run. The option to build from the real filings uses the same loader as the pipeline, but it has not been run on the full panel of 2,801,064 filings, because the raw files are not in the repository. The store is in memory.

No permission model. Actions are checked against the spec, but the actor is whatever name the caller passes. Peer groups are the pooled 2016 to 2024 cells; per-year cells are not modelled as objects.

Independent work. This is my own design, built on public OSHA data and the repository's own outputs. It is in the style of a Palantir Foundry Ontology but is not affiliated with or endorsed by Palantir, does not run on Foundry, and uses no employer data.

What it means for an EHS leader

When a benchmark says a site is in the worst quartile, someone should be able to see which filings were counted, which were set aside, who set them aside and why, and undo it. This design makes those answers part of the data model rather than a spreadsheet beside it.

Source: docs/ontology.md (Limits) · synthetic/README.md · outputs/summary.json

← All case studies The RPT report series