DEV Community

Cover image for Building an Ontology Schema for a Small Supply Network
Aniket Hingane
Aniket Hingane

Posted on

Building an Ontology Schema for a Small Supply Network

Cover

How I Defined Lanes, Shipments, and On-Time in One YAML Schema

TL;DR

I wanted one place that says what a shipment is, which lane it used, and what "on time" means, before any table or brief was allowed to invent a second meaning. LaneSchema is that place, as a personal experiment on a laptop. The ontology lives in schema/inland_network.yaml. The fact grain is Shipment. The dimensions are Node, Lane, Carrier, and Sku. The metrics I was willing to name are OnTime, FillPercent, DwellHours, and LateHours. A metric that points at a property the grain does not have dies in load_ontology with a SchemaError. I did that on purpose. I have wasted afternoons chasing a column that three notebooks spelled three ways.

The sample is a fictional inland network: a port in Los Angeles, distribution centers in Chicago, Dallas, and Minneapolis, and a store node in Austin. Fourteen shipments. Eight arrive inside a two-hour grace window. Six do not. The on-time rate is 57.1 percent. Average dwell at the origin dock is 4.05 hours. Average late hours, counting zeros for the on-time rows, is 2.0. The only lane under a 50 percent on-time rate is LN-CHI-DAL, at 1 of 4, which is 25 percent. Four shipments fill under 95 percent. Three notes contain a damage word I listed: crush, wet, leak. The full source is at https://github.com/aniket-work/LaneSchema.

python main.py prints that table. FastAPI serves the same build_report function. Nothing calls a model API. The grace window is a constant I picked so the sample would show both sides of the rule. It is not a carrier agreement, and the cities are not a study of real transit. I think the useful part is the order of work: write the question, pick one grain, name dimensions only when a row points at them, keep observations in properties, keep judgments in metrics, then refuse to score a row until the YAML validates.

Introduction

The first version of this experiment was a spreadsheet habit I kept repeating on personal projects. I would export a few rows, add a column called on time, and decide the formula in the header. A week later I would add another sheet, and the formula would grow a different grace window because I had forgotten the first one. The numbers still looked official. They were just two opinions wearing the same name.

Supply movements gave me a clean place to stop doing that. A trailer leaves a node, rides a lane, arrives at another node, and the bill names a carrier and a product family. That is a small world. I can hold it in one YAML file. I named the project LaneSchema. It is my PoC, not a network I operate, and not work I did for an employer.

I started from the question I actually wanted the brief to answer. Which lane in this sample is missing the plan often enough that I would look at it first? "Often enough" had to become a number or the question was a mood. I set the cutoff at an on-time rate under 50 percent. Then I had to say what on time is, or the cutoff was also a mood. I wrote it as a metric on the shipment: actual transit hours are on time when they are less than or equal to planned hours plus two. SH-05 is the boundary I care about. Planned 52, actual 54, grace 2, so 54 is still on time and LateHours is 0. SH-09 is the miss next to it. Planned 18, actual 24, so it is 4 hours late, fill is 100 percent, and the note is empty. If a brief only sorts by fill, SH-09 looks perfect. If a brief only sorts by damage words, SH-09 looks perfect. The ontology is what forces both facts to stay visible without turning "perfect" into a third metric I did not define.

I put the schema in YAML because I wanted to read it without a database. Classes, properties, relationships, metrics, constraints. The Python side loads that file, checks that every metric names a real property, binds data/network.json onto the classes, and scores. I did not build a general query language. Four rule types were enough for this sample: a grace comparison, a percent ratio, an alias, and a lateness remainder. From my experience, a tiny rule list that fails closed beats a string I eval at midnight.

The Chicago to Dallas lane is the one the brief flags. Four shipments, one on time. SH-01 makes it. SH-02, SH-09, and SH-14 do not. SH-02 is also short and crushed. SH-09 and SH-14 are full and unmarked. I like that mix. A lane problem and a freight problem are different sentences, and I wanted the schema to keep them in different fields.

What's This Article About?

I walk through how I built the ontology, in the order I wish I had used the first time. The question comes first. Then the fact grain. Then the dimensions. Then properties versus metrics. Then relationships. Then the checks that refuse a bad file. Then the binder. Then the brief. The code under Let's Get Cooking is the same code the tests run.

You will see why DwellHours is an alias of dwell_hours instead of a new calculation, why fill is a ratio of two properties and not a column I type by hand, and why a damage word is not a metric. The damage list is a scout on the note. I kept it out of the ontology on purpose, and I say where that choice gets thin.

Setup is the clone path on GitHub. The run section is the fourteen-row table, the weak-lane line, and the chart. If you only want the repo, start at https://github.com/aniket-work/LaneSchema. README.md has the same images and the same counts.

Tech Stack

I kept the list short. A schema PoC that needs a graph database before the first table is a PoC I will not rerun.

  1. Python 3.11 is the runtime I used while writing the tests.
  2. PyYAML reads schema/inland_network.yaml. I did not invent a second file format.
  3. Dataclasses hold the class, property, relationship, and metric definitions after the load.
  4. The validator in ontology.py rejects duplicate names, missing relationship targets, and metrics that name a property the grain does not have.
  5. The binder in bind.py checks keys, lane endpoints, and the two numeric constraints on units.
  6. FastAPI exposes GET /api/brief and serves static/dashboard.html.
  7. Rich prints the CLI table. I still judge these experiments by whether the terminal looks like a tool.
  8. Matplotlib writes the on-time bar chart, one bar per lane, with a line at 50 percent.
  9. pytest locks the 8 and 6 split, the SH-05 boundary, the SH-09 late-but-full case, and the unknown-property failure.

I skipped a triple store. Five classes do not need one. I skipped a hosted language model. The brief I wanted is a table of named metrics, and a model that paraphrases "on time" would be a second definition. If I add a model later, I want it rewriting a line of the brief into plainer words, with the metric enum left alone underneath.

Architecture

The picture matches the runtime. main.py and the dashboard both call build_report. That function asks metrics.py to score rows. metrics.py reads rules off the ontology object. bind.py is the only module that joins a shipment to a lane and a lane to two nodes. Sample rows stay in data/network.json so the article, the tests, and the GIF describe one run.

Why Read It?

Read it if you have a folder of notebooks that each define the same operational word. This experiment fails in an obvious way. If OnTime starts reading a property named revenue, test_metric_cannot_name_a_missing_property fails. There is no revenue in this project. I left money out on purpose. A price would have turned the brief into a negotiation, and that is a different experiment.

A few choices are the ones I would repeat.

  1. Pick the grain before the dimensions. I wrote Shipment as the only fact. Node and lane exist because a shipment points at them, not because a diagram looked empty without them.
  2. Put judgments in metrics and observations in properties. actual_hours is an observation. OnTime is a judgment with a grace constant. Mixing those in one column is how my old sheets drifted.
  3. Fail the file, not the row, when the schema is wrong. An unknown property is a schema bug. A late shipment is a data fact. Those should not share an error path.
  4. Keep one brief function. The CLI and the API do not each grow a copy of the grace rule. When I fixed the SH-05 boundary, both clients picked it up.
  5. Alias a measure you are tempted to rename. DwellHours points at dwell_hours. I got tired of "dock time" and "wait hours" showing up as silent synonyms.

I do not think this replaces a transportation system. I think it is a decent skeleton for a local brief when the raw record is already a form. The GitHub project includes the tests and the GIF so you can see the same 57.1 percent I am quoting here.

Let's Design

Before classes, I wrote the question and the cutoff on paper.

  1. Question. Which lanes have an on-time rate under 50 percent?
  2. Grain. One shipment, meaning one trailer movement on one lane.
  3. On time. actual_hours <= planned_hours + 2.
  4. Also show. Fill as a percent, dwell in hours, and hours past the grace window.

That list kept me from adding a class every time a noun sounded important. Weather, driver, and appointment window are real words in this domain. They are not in the file, because none of my questions used them. I would rather add a class when a metric needs it than decorate the YAML.

One fact, four dimensions

A semantic model gets muddy when two things both claim to be the row. I made Shipment the only kind: fact. Everything else is a dimension.

  1. Node is a place a trailer can leave or enter. Roles in the sample are port, dc, and store. The key is node_id.
  2. Lane is a directed hop. It has a mode, truck or rail, and two relationships, origin and destination, both many-to-one to Node.
  3. Carrier is the hauler on the bill. Two of them exist in the sample, Northhaul and Redline. I did not give the carrier a score. A score would have been a new judgment, and I did not have a question that needed it yet.
  4. Sku is the product family. Oats and soap. I did not model cases, pallets, or lots. The fill metric only needs units ordered and units shipped on the shipment.

The shipment relationships are travels to Lane, hauled_by to Carrier, and contains to Sku. All many-to-one. I did not implement many-to-many. A trailer with two families would need a line-level grain, and then fill would have to move. I kept one family per shipment so the grain stayed honest. That is a limit, and I would rather state it than pretend the YAML is a full network model.

Sequence

Properties are what I measured

On Shipment the properties are the things I could imagine reading off a bill or a dock clock.

  1. planned_hours and actual_hours are transit, door to door, not including the dock sit. I split dwell out so a slow departure would not masquerade as a slow road.
  2. dwell_hours is the sit at the origin dock before departure. It is not dwell at the destination. I only had one clock in the sample, and I named it.
  3. units_ordered and units_shipped are counts. Fill is not stored.
  4. note is free text. The ontology does not interpret it. The report looks for three words after scoring.

Each property has a one-sentence definition in the YAML. I wrote those sentences for myself. When I came back two days later, "door to door" stopped me from adding dwell into actual_hours "to make the chart more dramatic." The chart got less dramatic. The metric got clearer.

Metrics are the judgments

Four metrics, all on Shipment.

  1. OnTime uses rule type within_grace. Actual, planned, grace hours 2. The result is a boolean.
  2. FillPercent uses percent_ratio. Numerator units_shipped, denominator units_ordered, times 100.
  3. DwellHours uses alias on dwell_hours. Same number, one name the brief is allowed to print.
  4. LateHours uses lateness. Hours past the grace window, floored at zero. When OnTime is true, LateHours is 0. I checked SH-05 for that. Planned 52 plus grace 2 is 54, actual is 54, late hours 0.

I almost added OnTimeRate as a fifth metric on the shipment. That was wrong. A rate is an aggregate over many shipments, grouped by lane. The ontology names the row-level judgment. lane_on_time in metrics.py does the grouping. If I had stored a rate on the row, every shipment on a lane would have carried a number that depends on its neighbors, and the JSON diff habit would have gotten muddy.

Grace lives on the metric rule, not on the shipment. I want one grace for this experiment. A per-lane grace is a real idea. It would be a property on Lane and a change to the rule, not a silent edit in the scorer. I have not done that change.

Constraints the binder actually checks

The YAML lists three constraints in prose, and the binder enforces the same three in code. I do not parse the prose. The prose is there so I remember why the raise exists.

  1. A lane origin and destination must differ. I do not have a loop lane in the sample, and I do not want one created by a typo.
  2. units_shipped must be less than or equal to units_ordered. Over-ship would have made fill exceed 100, and I did not want a second story about extras.
  3. units_ordered must be greater than zero so the ratio has a denominator.

Unknown relationship targets fail earlier, in validate_ontology, before any JSON is read. Duplicate class names fail there too. Duplicate shipment ids fail in the binder. I split those layers after I had a single function that mixed "your YAML is nonsense" with "this row is late" and the traceback was useless.

Workflow

The flow chart is the same story with the exits drawn. A metric that names a missing property stops. A lane whose ends match stops. Everything else is scored, grouped, and printed. The 50 percent line is a reporting cutoff in build_report, not a class in the ontology. I drew it on the chart as a dashed line so I would remember it is mine.

What I left out of the schema

Damage terms are the obvious omission. crush, wet, and leak live as a tuple in ontology.py and the scorer scans the note. I thought about a metric called Damaged with a keyword rule. I left it out because a keyword list is a scout, and I have watched scouts pretend to be definitions. The brief still prints the words. They do not change OnTime. SH-02 is late and crushed. SH-09 is late and clean. Both are late for the same metric. The note is extra, and I want it to stay extra.

I also left carrier performance out. Let me be exact, because this is the kind of sentence I get wrong if I trust memory. The late ids are SH-02, SH-04, SH-06, SH-08, SH-09, and SH-14. All six are REDLINE. The on-time rows are all NORTHHAUL except SH-12, which is Redline, on time, with 7.8 hours of dwell. So Redline is not "always late," and Northhaul is not a moral category. I noticed the skew after the table printed. I did not add a carrier metric to celebrate the skew. Fourteen rows is a toy. A carrier score on a toy becomes a costume.

Let's Get Cooking

This is the part I reread when I forget which check belongs to the file and which belongs to the row.

The metric block

I wrote the judgments in YAML so the grace constant would be visible without opening the scorer.

metrics:
  - name: OnTime
    grain: Shipment
    definition: Arrived no later than the plan plus two hours.
    rule:
      type: within_grace
      actual: actual_hours
      planned: planned_hours
      grace_hours: 2
  - name: FillPercent
    grain: Shipment
    definition: Units shipped divided by units ordered, as a percent.
    rule:
      type: percent_ratio
      numerator: units_shipped
      denominator: units_ordered
  - name: DwellHours
    grain: Shipment
    definition: Hours the trailer sat at the origin dock. Alias of dwell_hours so a second name cannot drift.
    rule:
      type: alias
      property: dwell_hours
Enter fullscreen mode Exit fullscreen mode

The definition strings are for humans. The rule block is what Python reads. I kept both because a comment above a function drifted on me last month, and the YAML definition sits next to the numbers.

Refuse a metric that invents a property

validate_ontology walks each metric and checks the property names against the grain class. This is the whole point of compiling the schema before scoring.

def _check_rule(metric: MetricDef, grain: ClassDef) -> None:
    rule = metric.rule
    kind = rule.get("type")
    if kind not in RULE_TYPES:
        raise SchemaError(f"{metric.name} has unknown rule type {kind}")
    props = grain.property_names()
    if kind == "alias":
        _require_prop(metric.name, props, rule["property"])
    elif kind == "percent_ratio":
        _require_prop(metric.name, props, rule["numerator"])
        _require_prop(metric.name, props, rule["denominator"])
    elif kind in {"within_grace", "lateness"}:
        _require_prop(metric.name, props, rule["actual"])
        _require_prop(metric.name, props, rule["planned"])
        if float(rule["grace_hours"]) < 0:
            raise SchemaError(f"{metric.name} grace_hours must be >= 0")


def _require_prop(metric: str, props: set[str], name: str) -> None:
    if name not in props:
        raise SchemaError(f"{metric} references unknown property {name}")
Enter fullscreen mode Exit fullscreen mode

I tested this with a metric called GhostRate that aliases revenue. The load raises SchemaError and the message contains unknown property revenue. I want that failure. A scorer that treats a missing name as zero would have printed a calm table and I would have trusted it.

Relationships get a similar check. Lane.origin must target a class that exists. The test flips that target to Warehouse and expects missing class Warehouse. I have created that class in my head three times. The file should complain until I actually add it.

Score only what the metric declared

The scorer does not know the word "shipment" beyond the dict of values. It knows the rule type.

def score_metric(metric: MetricDef, row: ShipmentRow) -> float | bool:
    rule = metric.rule
    kind = rule["type"]
    values = row.values
    if kind == "alias":
        return float(values[rule["property"]])
    if kind == "percent_ratio":
        denom = float(values[rule["denominator"]])
        return 100.0 * float(values[rule["numerator"]]) / denom
    actual = float(values[rule["actual"]])
    planned = float(values[rule["planned"]])
    grace = float(rule["grace_hours"])
    if kind == "within_grace":
        return actual <= planned + grace
    if kind == "lateness":
        return max(0.0, actual - planned - grace)
    raise ValueError(f"unknown rule {kind}")
Enter fullscreen mode Exit fullscreen mode

The <= on the grace comparison is the line I got wrong once. I had written <, and SH-05, which lands exactly on planned plus two, flipped to late. The test test_grace_boundary_is_on_time is that argument written down. Late hours uses max(0, actual - planned - grace), so the same boundary stays at zero. I want those two rules to agree. If they ever disagree, I broke the ontology even if the table still prints.

Bind the lane before you trust the id

A shipment string that says travels: LN-CHI-DAL is not a lane until the binder finds that id and then finds both nodes.

if row["units_ordered"] <= 0:
    raise SchemaError(f"{ident} units_ordered must be > 0")
if row["units_shipped"] > row["units_ordered"]:
    raise SchemaError(f"{ident} units_shipped exceeds units_ordered")
lane = indexes["Lane"].get(row["travels"])
if lane is None:
    raise SchemaError(f"{ident} travels unknown lane {row['travels']}")
Enter fullscreen mode Exit fullscreen mode

Earlier in the same function, a lane whose origin equals its destination raises before any shipment is built. I hit that with a typo, DC-CHI to DC-CHI, while I was sketching the file. The error was more useful than a shipment that traveled nowhere and still produced a fill percent.

Group by lane after the row score

The weak-lane question is not a metric. It is a fold.

def lane_on_time(ontology: Ontology, rows: tuple[ShipmentRow, ...]) -> list[dict]:
    buckets: dict[str, list[bool]] = {}
    for row in rows:
        flag = bool(score_metric(ontology.metric_named("OnTime"), row))
        buckets.setdefault(row.lane["lane_id"], []).append(flag)
    report = []
    for lane_id, flags in buckets.items():
        rate = sum(1 for flag in flags if flag) / len(flags)
        report.append(
            {
                "lane_id": lane_id,
                "shipments": len(flags),
                "on_time": sum(1 for flag in flags if flag),
                "on_time_rate": rate,
            }
        )
    report.sort(key=lambda item: (item["on_time_rate"], item["lane_id"]))
    return report
Enter fullscreen mode Exit fullscreen mode

build_report then keeps lanes whose rate is under 0.5. On this file that list is exactly ["LN-CHI-DAL"]. The sort is rate, then id, so the worst lane prints first and a tie would not shuffle when I add a row. I learned to sort ties after a test failed only because dict order changed.

Fill under 95 percent is another fold, short_fills. The four ids are SH-02 at 90, SH-04 at 83.3, SH-06 at 90, and SH-08 at 80. SH-10 shipped 210 of 220, which is 95.5, so it stays out. I picked 95 because I wanted a cutoff that SH-10 would clear and SH-02 would not. That is an experimental choice. I say it plainly so nobody treats 95 as a receiving policy.

Dwell stays on the origin

origin_dwell averages DwellHours by the lane's origin node. Los Angeles, the port, averages 4.8 hours across the four rail shipments. Chicago averages 4.63 hours across seven shipments, the ones that leave on LN-CHI-DAL or LN-CHI-MSP. Dallas averages 1.7 hours across the three Austin hops. Chicago is busy in this sample and also hosts the weak lane. I do not conclude that dwell caused the late arrivals. SH-12 sat 7.8 hours in Chicago and was still on time to Minneapolis. SH-08 sat only 2.4 hours and was 4 hours late. The schema lets me see both. It does not invent a cause.

A shipment, field by field

I keep SH-02 on a card next to the YAML when I explain this to myself. It is the row that trips three different ideas at once, and if the schema is doing its job those ideas do not collapse into one score.

The JSON says shipment SH-02, planned 18 hours, actual 23, dwell 6.2, ordered 400, shipped 360, note "pallet crush on row 2", travels LN-CHI-DAL, hauled by Redline, contains soap. The binder does not trust the lane string. It looks up LN-CHI-DAL, finds mode truck, origin DC-CHI, destination DC-DAL, then looks up both nodes. Chicago is a dc. Dallas is a dc. If either node were missing, I would rather crash here than print a fill percent for a hop into nowhere.

Then the four metrics, and only those four.

  1. OnTime compares 23 with 18 plus 2. 23 is greater than 20, so the flag is false.
  2. LateHours is 23 minus 18 minus 2, which is 3.0. The same grace, the same two properties, a remainder instead of a boolean.
  3. FillPercent is 100 times 360 divided by 400, which is 90. The 95 percent fold will keep this row. That fold is not in the YAML.
  4. DwellHours copies 6.2. It does not add dwell to the transit hours, because I wrote the property definitions to keep those clocks apart.

The note is scanned after that. crush hits. The row is late, short, and marked. SH-09, two lines below it in the file, is also on LN-CHI-DAL and also late, by 4.0 hours, with fill at 100 and an empty note. Same lane metric, different freight story. I put it this way because the first time I grouped only by lane I almost wrote "Chicago-Dallas shipments are damaged." Three of the four are not. The schema did not save me from that sentence. The columns did, once I forced myself to read them.

Carrier is the column I almost overread. All six late shipments are Redline, and SH-12 is Redline and on time. I wrote a sentence in an early draft that said Redline was the problem. Then I looked at SH-12's dwell, 7.8 hours, and at SH-01, Northhaul, 1.5 hours of dwell, on time, same lane as the misses. The ontology has a hauled_by relationship so I can see the code. It does not have a carrier score, so the brief cannot launder that glimpse into a ranking. In my opinion that omission is the grown-up part of an otherwise small file. A class can exist so you can point at it, without a metric that pretends you already know what the pointing means.

If I were handing this PoC to a future version of me, I would say: add a metric only when you can write the test that should fail if the constant moves. SH-05 is that test for grace. The short-fill set is that test for 95. The weak-lane list is that test for 50 percent. Anything I cannot pin that way stays out of the YAML, even if it would make the README sound larger.

Let's Setup

Step by step details can be found at https://github.com/aniket-work/LaneSchema.

git clone https://github.com/aniket-work/LaneSchema.git
cd LaneSchema
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
pytest
python main.py --print-schema --chart images/ontime-chart.png
Enter fullscreen mode Exit fullscreen mode

The virtualenv belongs inside the project directory. I do that so this experiment does not leak packages into whatever else I am running. pytest should show five passed tests. If GhostRate stops failing, the validator got softer and I would not trust the table.

The dashboard is optional.

uvicorn app:app --port 8000
Enter fullscreen mode Exit fullscreen mode

Then open the local page and press the button. It calls GET /api/brief, which calls run, which calls build_report. Same function as the CLI. There is no authentication. I run it on my laptop and I stop the process when I am done looking.

Let's Run

--print-schema is the compile step I want to see before the numbers. It prints the ontology name, the fact grain, each class with its relationships, and each metric with its definition sentence. If that list does not mention OnTime, I am looking at the wrong file.

The table for the sample, abbreviated to the rows I keep talking about, looks like this when I run it:

ID     LANE         ONTIME    LATE    FILL  DWELL NOTE
SH-01  LN-CHI-DAL   yes        0.0  100.0%    1.5 -
SH-02  LN-CHI-DAL   no         3.0   90.0%    6.2 crush
SH-05  LN-LA-CHI    yes        0.0  100.0%    4.0 -
SH-09  LN-CHI-DAL   no         4.0  100.0%    5.5 -
SH-14  LN-CHI-DAL   no         7.0  100.0%    8.0 -
shipments=14 on_time=8 late=6 on_time_rate=57.1% avg_dwell=4.05h damage_notes=3
weak lanes (OnTime rate under 50%): LN-CHI-DAL
Enter fullscreen mode Exit fullscreen mode

Lane rates, worst first:

  1. LN-CHI-DAL is 1 of 4, 25 percent. Truck, Chicago to Dallas.
  2. LN-CHI-MSP is 2 of 3, 66.7 percent. Truck, Chicago to Minneapolis.
  3. LN-DAL-AUS is 2 of 3, 66.7 percent. Truck, Dallas to Austin.
  4. LN-LA-CHI is 3 of 4, 75 percent. Rail, Los Angeles to Chicago.

The chart colors a bar red under 50 and green otherwise, and draws the cutoff. I regenerate it with --chart whenever the JSON changes. A stale PNG is worse than no PNG, because I will quote it.

The GIF at the top types python main.py --chart images/ontime-chart.png, scrolls the summary, holds on the weak-lane line, then cuts to four cards: 8 on time, 6 late, 25 percent on Chicago-Dallas, 75 percent on Los Angeles-Chicago. If your run disagrees with those cards, trust the terminal. I have published a picture that drifted from the code, and I do not want to do it again.

Cases I had to pin with tests

I do not add a test for every row. I add one when a rule exists only because I got burned.

  1. The sample counts stay 14 shipments, 8 on time, 6 late, weak lanes exactly LN-CHI-DAL, and 3 damage notes. The short-fill set stays SH-02, SH-04, SH-06, SH-08. When I add a fifteenth shipment I have to edit this test on purpose.
  2. SH-05 is on time and late hours are 0. That protects the inclusive grace.
  3. SH-09 is late by 4.0 hours, fill is 100, and the damage list is empty. That protects me from "helpfully" marking every late row as damaged.
  4. A metric that aliases revenue raises SchemaError. That protects the compile step.
  5. A relationship aimed at a class named Warehouse raises before scoring. That protects me from a diagram I only drew on paper.

The first test is the regression net for the whole batch. I want that friction. A schema experiment that updates its own expected counts inside the run is just a printer.

Where the experiment is thin

The grace constant is thin. I moved it until SH-05 sat on the boundary and SH-09 fell four hours past it. That is a good way to test a comparison and a bad way to claim I measured a lane. There is no appointment window, no detention rule, and no clock timezone. Hours are floats I typed.

The keyword note is thin in a different way. "Crushed" would slip past crush if I had stemmed nothing and also forgotten the substring. crush happens to match inside "crushed," which is luck, not a design. "batteries" taught me this on another personal experiment. I left the list dumb on purpose: three words, lowercase, no word boundaries. A future note that says the crush was already refused at the dock would still flag. I would rather see that miss, or that false hit, than pretend the scout is a claims process.

Carrier skew is thin because I noticed it after I wrote the rows. If I had written the rows to make Redline look bad, the brief would be a cartoon. I did not plan the skew. I also did not correct the data to hide it. Fourteen handwritten shipments can do that. A larger extract would need a held-out file I did not use while tuning the cutoff. I do not have one yet.

Timestamps are absent. I stored hours, not instants. A fuller experiment would group by day and complain when a trailer is counted twice. I did not need that to prove the compiler. Unused fields bother me less than fields that silently change OnTime.

FastAPI is thicker than the PoC needs. I kept it because I wanted a second client on the same function. The HTML page is not a product surface. Do not expose it and pretend the weak-lane list is an operating picture.

What I would add if I keep going

These are notes to myself.

  1. A per-lane grace as a property on Lane, with a test that a two-hour miss is late on one lane and on time on another. The rule would read the lane, and the shipment metric would still be the only OnTime.
  2. A line-level grain for mixed trailers, which means fill moves off Shipment and the current ratio test has to be rewritten on purpose.
  3. Word-boundary matching on the damage list, plus a note that says the damaged cases were removed before departure.
  4. A held-out JSON file of shipments I did not stare at while picking 50 percent and 95 percent.
  5. A diff of two ontology versions that lists metric definition changes in sentences, so I can see when I edited grace and forgot to say so.

I would not add a chat box in front of the table. Every time I have done that in a personal project, I stop looking at the late hours. The late hours are what caught the < versus <= bug. I am keeping them in the foreground.

How I checked the pictures

The cover GIF is a terminal, then the four cards and the chart. The terminal types the same command I use locally and holds on the 57.1 percent line and the weak lane. The mermaid figures are the title view, the module map, the sequence from YAML load to the ASCII table, and the flow with the two early exits. They live under images/ in the repo. I regenerated the flow once after a label got too clever and the renderer dropped the node. The version in the repo uses plainer words. The behavior in code still raises SchemaError.

Title diagram

Closing Thoughts

LaneSchema is a small ontology of a made-up inland network, plus a brief I can run with no key and no network. Shipment is the grain. Node, Lane, Carrier, and Sku are the dimensions a shipment is allowed to point at. On time, fill, dwell, and late hours are the judgments I wrote down, and the loader refuses a judgment that names a property I never declared. Chicago to Dallas is the only lane under 50 percent in this sample, at 25 percent, and that flag is a fold over the metric, not a second definition hiding in the printer.

I will keep using this order on later personal experiments. Write the question. Pick one grain. Add a dimension only when a relationship needs it. Store observations as properties. Store judgments as metrics with the constant visible in the file. Fail the file when the names do not line up. Score the rows after that, and let aggregates live in the report. The domain can change. The laptop can stay the place it runs. The repository is https://github.com/aniket-work/LaneSchema if you want the same fourteen shipments I used while writing this.

The chart and the table will go stale the moment you edit the grace or the JSON. That is the correct failure. Update the test counts when you mean to, and leave them failing when you do not.

Disclaimer

The views and opinions expressed here are solely my own and do not represent the views, positions, or opinions of my employer or any organization I am affiliated with. The content is based on my personal experience and experimentation and may be incomplete or incorrect. Any errors or misinterpretations are unintentional, and I apologize in advance if any statements are misunderstood or misrepresented.

Tags: python, ontology, supplychain, opensource

Top comments (0)