<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Aniket Hingane</title>
    <description>The latest articles on DEV Community by Aniket Hingane (@exploredataaiml).</description>
    <link>https://dev.to/exploredataaiml</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F946058%2F1350ea8e-445d-4052-b72a-95fd786a4f5c.jpeg</url>
      <title>DEV Community: Aniket Hingane</title>
      <link>https://dev.to/exploredataaiml</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/exploredataaiml"/>
    <language>en</language>
    <item>
      <title>One YAML File for a Town Archive's Condition Calls</title>
      <dc:creator>Aniket Hingane</dc:creator>
      <pubDate>Mon, 19 Oct 2026 03:28:16 +0000</pubDate>
      <link>https://dev.to/exploredataaiml/one-yaml-file-for-a-town-archives-condition-calls-4gm7</link>
      <guid>https://dev.to/exploredataaiml/one-yaml-file-for-a-town-archives-condition-calls-4gm7</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8xhfhqytb3wmb54zly90.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8xhfhqytb3wmb54zly90.gif" alt="Cover" width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How I Defined Treatment, Stability, and Digitization in One Model&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I kept three notebooks for a fake town archive and they would not agree on a single object. The catalog notebook said "watch it." The conservation notebook said "queue it." The digitization notebook said "scan the tapes, all of them." Same twelve rows. Three moods wearing column headers. FolioSchema is the file I wrote so those moods had to become rules with names. It is a laptop experiment. Cedar Hollow is invented. Nobody accessioned these boxes.&lt;/p&gt;

&lt;p&gt;The semantic model is &lt;code&gt;schema/archive.yaml&lt;/code&gt;. The fact grain is &lt;code&gt;Item&lt;/code&gt;. The dimensions are &lt;code&gt;Collection&lt;/code&gt;, &lt;code&gt;Location&lt;/code&gt;, and &lt;code&gt;Format&lt;/code&gt;. The judgments I was willing to name are &lt;code&gt;NeedsTreatment&lt;/code&gt;, &lt;code&gt;Stable&lt;/code&gt;, &lt;code&gt;DigitizeNext&lt;/code&gt;, and &lt;code&gt;PullRisk&lt;/code&gt;. A metric that points at a property the item does not have dies in &lt;code&gt;load_ontology&lt;/code&gt; with a &lt;code&gt;SchemaError&lt;/code&gt;. I added a test that tries to score &lt;code&gt;appraisal_dollars&lt;/code&gt; and expects that death. There is no money in this project. A price would have turned a condition call into a negotiation, and that is a different experiment.&lt;/p&gt;

&lt;p&gt;The sample is twelve objects. Six have a condition score of 2 or lower, so they need treatment. Five have a score of 4 or higher, so they are stable. One sits at 3, which is neither. The treatment rate is 50.0 percent. Seven items are next for digitization. Two are a pull risk. Average score is 2.83. Six notes contain a word I listed as a scout: foxing, brittle, curl, silvering, sticky, shed. Oral history tapes are the collection I would open first, 3 of 4. Flood photographs are on the line, 2 of 4. Town minutes are 1 of 4, under the line. The full source is at &lt;a href="https://github.com/aniket-work/FolioSchema" rel="noopener noreferrer"&gt;https://github.com/aniket-work/FolioSchema&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;python main.py&lt;/code&gt; prints that table. FastAPI serves the same &lt;code&gt;build_report&lt;/code&gt; function. Nothing calls a model API. The cutoffs are constants I picked so the sample would show both sides of each rule. Score 2 is the treatment line. Score 4 is the stable line. Score 3 plus "not digitized," or a year of 1940 or earlier plus "not digitized," is the digitization line. Five pulls plus a score of 3 or lower is the pull line. A 50 percent treatment rate is the collection flag, and it lives in the report, not in the YAML. From my experience, if the flag lives in the model, I start treating a reporting habit as a law.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;The first version of this experiment was a spreadsheet habit. I would export a few rows about a personal project, add a column called status, and decide the formula in the header while the coffee was still hot. A week later I would open a second sheet for a second question, and the formula would shift by one point on the scale because I had forgotten the first sheet. Both sheets still looked official. They were two opinions.&lt;/p&gt;

&lt;p&gt;A small archive gave me a clean place to stop doing that. I did not want a hospital, a warehouse, or a ledger. I wanted objects on shelves. A volume of minutes, a flood photograph, a reel of tape. Each one has a year, a place in the building, a carrier, a condition score I assigned, a yes or no for "a usable surrogate exists," a count of how often a reader pulled it, and a note. That is a small world. I can hold it in one YAML file and one JSON file. I named the project FolioSchema. It is my PoC. Cedar Hollow is a town I made up so the rows would have a story. It is not a reading room I staff, and it is not work I did for an employer.&lt;/p&gt;

&lt;p&gt;I started from the question I actually wanted the brief to answer. Which collection in this sample is worn badly enough that I would open that box first? "Badly enough" had to become a number or the question was a mood. I set the collection cutoff at a needs-treatment rate of 50 percent or higher. Then I had to say what needs treatment is, or the cutoff was also a mood. I wrote it as a metric on the item: condition score is 2 or lower. Score 3 is watched. It is not queued. That single choice is the whole project, and the rest of the file exists so I cannot quietly undo it in a chart title.&lt;/p&gt;

&lt;p&gt;IT-10 is the boundary I care about for treatment. Oral history tape, 1980, score exactly 2, not digitized, pulled twice, empty note. &lt;code&gt;NeedsTreatment&lt;/code&gt; is yes. If I had written the rule as "score under 2," IT-10 would have fallen out of the queue and the tape collection would have been 2 of 4 instead of 3 of 4. I wanted that boundary in the YAML, next to the definition sentence, not buried in a &lt;code&gt;&amp;lt;=&lt;/code&gt; I would misread in March.&lt;/p&gt;

&lt;p&gt;IT-01 is the split I care about between treatment and digitization. Council minutes, 1924, score 3, not digitized, pulled four times, note says foxing. Treatment says no. Stable says no. Digitization says yes, because there is no surrogate and the score is 3 or lower. A sheet that collapses "do something" into one column cannot say that sentence. I got tired of one column.&lt;/p&gt;

&lt;p&gt;IT-07 is the holdout I care about for digitization. Flood photograph, 1978, score 5, not digitized, pulled six times, empty note. It is stable. It is not next to scan. It is not a pull risk, because the score is 5, and pull risk requires a score of 3 or lower. A popular healthy print can sit undigitized in this model. I think that is correct for this experiment. A rule that said "scan whatever readers touch" would have flagged IT-07 and IT-03 together, and IT-03 is already digitized and sound. Touch count is not condition. I kept them in different fields so I would stop mixing them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9rcy4bbnjktq59uds3mj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9rcy4bbnjktq59uds3mj.png" alt="Title diagram" width="800" height="654"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's This Article About?
&lt;/h2&gt;

&lt;p&gt;I walk through how I built the model, 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.&lt;/p&gt;

&lt;p&gt;You will see why score 3 is a gap on purpose, why &lt;code&gt;DigitizeNext&lt;/code&gt; can be true when &lt;code&gt;NeedsTreatment&lt;/code&gt; is false, and why a damage word is not a metric. The damage list is a scout on the note. I kept it out of the model on purpose, and I say where that choice gets thin.&lt;/p&gt;

&lt;p&gt;Setup is the clone path on GitHub. The run section is the twelve-row table, the tape line, and the chart. If you only want the repo, start at &lt;a href="https://github.com/aniket-work/FolioSchema" rel="noopener noreferrer"&gt;https://github.com/aniket-work/FolioSchema&lt;/a&gt;. &lt;code&gt;README.md&lt;/code&gt; has the same images and the same counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;p&gt;I kept the list short. A model experiment that needs a graph database before the first table is an experiment I will not rerun.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Python 3.11&lt;/strong&gt; is the runtime I used while writing the tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PyYAML&lt;/strong&gt; reads &lt;code&gt;schema/archive.yaml&lt;/code&gt;. I did not invent a second file format.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dataclasses&lt;/strong&gt; hold the class, property, relationship, and metric definitions after the load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The validator in &lt;code&gt;ontology.py&lt;/code&gt;&lt;/strong&gt; rejects duplicate names, missing relationship targets, and metrics that name a property the grain does not have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The binder in &lt;code&gt;bind.py&lt;/code&gt;&lt;/strong&gt; checks keys, relationship targets, and the numeric windows on score, year, and pulls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FastAPI&lt;/strong&gt; exposes &lt;code&gt;GET /api/brief&lt;/code&gt; and serves &lt;code&gt;static/dashboard.html&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Matplotlib&lt;/strong&gt; writes the treatment-rate bar chart, one bar per collection, with a line at 50 percent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pytest&lt;/strong&gt; locks the 6 and 5 and 1 split, the IT-10 boundary, the IT-01 split, the IT-07 holdout, and the unknown-property failure.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I skipped a triple store. Four classes do not need one. I skipped a hosted language model. The brief I wanted is a table of named booleans, and a model that paraphrases "needs treatment" 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 names left alone underneath. I also skipped a database. Twelve rows fit in JSON. A catalog server would have let me hide the rules inside a view, which is the habit I was trying to quit.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwv59jyk1dnvqru49840v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwv59jyk1dnvqru49840v.png" alt="Architecture" width="799" height="484"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The picture matches the runtime. &lt;code&gt;main.py&lt;/code&gt; and the dashboard both call &lt;code&gt;build_report&lt;/code&gt;. That function asks &lt;code&gt;metrics.py&lt;/code&gt; to score rows. &lt;code&gt;metrics.py&lt;/code&gt; reads rules off the ontology object. &lt;code&gt;bind.py&lt;/code&gt; is the only module that joins an item to a collection, a location, and a format. Sample rows stay in &lt;code&gt;data/archive.json&lt;/code&gt; so the article, the tests, and the GIF describe one run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Read It?
&lt;/h2&gt;

&lt;p&gt;Read it if you have a folder of notebooks that each define the same operational word. This experiment fails in an obvious way. If &lt;code&gt;NeedsTreatment&lt;/code&gt; starts reading a property named &lt;code&gt;appraisal_dollars&lt;/code&gt;, &lt;code&gt;test_metric_cannot_name_a_missing_property&lt;/code&gt; fails. I left money out on purpose. Once a dollar amount sits next to a condition score, the queue becomes a story about what is worth saving, and I did not have a question that asked that.&lt;/p&gt;

&lt;p&gt;A few choices are the ones I would repeat.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pick the grain before the dimensions.&lt;/strong&gt; I wrote &lt;code&gt;Item&lt;/code&gt; as the only fact. Collection, location, and format exist because an item points at them, not because a diagram looked empty without them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put judgments in metrics and observations in properties.&lt;/strong&gt; &lt;code&gt;condition_score&lt;/code&gt; is an observation. &lt;code&gt;NeedsTreatment&lt;/code&gt; is a judgment with a maximum. Mixing those in one column is how my old sheets drifted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail the file, not the row, when the model is wrong.&lt;/strong&gt; An unknown property is a schema bug. A brittle volume is a data fact. Those should not share an error path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep one brief function.&lt;/strong&gt; The CLI and the API do not each grow a copy of the score cutoff. When I fixed the IT-10 boundary in my head and wrote &lt;code&gt;&amp;lt;= 2&lt;/code&gt; into the YAML, both clients picked it up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leave a gap between the bad call and the good call.&lt;/strong&gt; Score 3 is neither treatment nor stable. I wanted a row that the brief cannot flatter. IT-01 is that row.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I do not think this replaces a collections management 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 50.0 percent I am quoting here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Design
&lt;/h2&gt;

&lt;p&gt;Before classes, I wrote the question and the cutoff on paper.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Question.&lt;/strong&gt; Which collections have a needs-treatment rate at or above 50 percent?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grain.&lt;/strong&gt; One item, meaning one physical object, not a box and not a patron.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Needs treatment.&lt;/strong&gt; &lt;code&gt;condition_score &amp;lt;= 2&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Also show.&lt;/strong&gt; Stable at 4 or higher, digitization when there is no surrogate and the object is weak or early, and pull risk when readers keep asking for a weak object.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That list kept me from adding a class every time a noun sounded important. Patron, exhibition, and appraisal are real words around archives. 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  One fact, three dimensions
&lt;/h3&gt;

&lt;p&gt;A semantic model gets muddy when two things both claim to be the row. I made &lt;code&gt;Item&lt;/code&gt; the only &lt;code&gt;kind: fact&lt;/code&gt;. Everything else is a dimension. I am using "semantic model" here in the plain sense: a file that names the things, the links, and the judgments, separate from the rows. The JSON is closer to a physical listing. It says IT-04 was made in 1911 and scored 1. It does not say that 1 means queue it. The YAML says that. A semantic layer, in the products I have tried, is usually a service that sits in front of a warehouse and answers queries with those names. I did not build that service. I built the file and a scorer. An ontology, in the academic sense, carries axioms and a reasoner that can infer facts I did not type. I did not build that either. Four rule types and a validator were enough. I would rather say that out loud than let the word "ontology" in the module name do extra work. The Python package is called that because the file is the vocabulary. It does not infer a new item.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Collection&lt;/strong&gt; is the group I want the rate over. Town council minutes, flood photographs, oral history tapes. The key is &lt;code&gt;collection_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Location&lt;/strong&gt; is where the object sits in the fake building. Vault, reading room, cold room. Each has a climate label: controlled, open, or cold. The label is not a sensor. I have no temperature series, so I refused to invent one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Format&lt;/strong&gt; is the carrier. Paper, photograph, magnetic tape. I did not give format its own metric. A rule that said "every tape needs treatment" would have flagged IT-11, and IT-11 is score 4 and already digitized. Carrier is a dimension an item points at. It is not a verdict.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The item relationships are &lt;code&gt;held_in&lt;/code&gt; to Collection, &lt;code&gt;stored_at&lt;/code&gt; to Location, and &lt;code&gt;recorded_as&lt;/code&gt; to Format. All many-to-one. I did not implement many-to-many. An object that belongs to two collections would need a link table, and then the rate would have to say whether the item counts twice. I kept one collection per item so the grain stayed honest. That is a limit, and I would rather state it than pretend the YAML is a full finding aid.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp2bx7ljxmf33skvdbcd4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp2bx7ljxmf33skvdbcd4.png" alt="Sequence" width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Properties are what I observed
&lt;/h3&gt;

&lt;p&gt;On &lt;code&gt;Item&lt;/code&gt; the properties are the things I could imagine reading off a folder label.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;year_created&lt;/code&gt; is the year the object was made. Accession year is a different fact. I did not store it, because none of the metrics use it. IT-08 is a 1941 photograph. If I had used the year I "found" it in a drawer, the digitization rule would have moved, and I would not have noticed until the chart looked kinder.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;condition_score&lt;/code&gt; is an integer from 1 to 5. 1 is failing. 5 is sound. I assigned every score myself for this sample. They are not a conservator's survey. The binder rejects 0 and 6 so a typo cannot become a new end of the scale.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;digitized&lt;/code&gt; is true only when a usable surrogate exists. I decided that a filename placeholder would be false. In this sample the true rows are IT-02, IT-03, IT-06, and IT-11. The other eight are false.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;handling_count&lt;/code&gt; is how many times the object was pulled in the sample year. IT-03 was pulled 8 times and is score 5. IT-08 was pulled 0 times and is score 1. Popularity and decay are different sentences.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;note&lt;/code&gt; is free text. The model does not interpret it. The report looks for six words after scoring.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each property has a one-sentence definition in the YAML. I wrote those sentences for myself. When I came back the next day, "usable surrogate" stopped me from flipping IT-07 to digitized just because I could imagine a scan. The chart got less flattering. The metric got clearer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Metrics are the judgments
&lt;/h3&gt;

&lt;p&gt;Four metrics, all on &lt;code&gt;Item&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;NeedsTreatment&lt;/strong&gt; uses rule type &lt;code&gt;at_most&lt;/code&gt;. Property &lt;code&gt;condition_score&lt;/code&gt;, maximum 2. The result is a boolean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stable&lt;/strong&gt; uses &lt;code&gt;at_least&lt;/code&gt;. Property &lt;code&gt;condition_score&lt;/code&gt;, minimum 4. Score 3 matches neither rule. I checked IT-01 for that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DigitizeNext&lt;/strong&gt; uses &lt;code&gt;undigitized_and&lt;/code&gt;. The flag property must be false. Then either the score is 3 or lower, or the year is 1940 or earlier. Already-digitized rows return false before the score is considered. IT-04 is 1911 and score 1 and not digitized, so it is next. IT-02 is 1948 and score 4 and digitized, so it is not next, even though 1948 is not early enough to matter. The year clause is there for a sound old object that still has no surrogate. This sample does not contain that object. I left the clause in because I know I will want it the moment I add a score-5 volume from 1890, and I would rather see the clause now than discover I hardcoded "score only."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PullRisk&lt;/strong&gt; uses &lt;code&gt;both_bounds&lt;/code&gt;. Score at or below 3, and pulls at or above 5. Both must hold. IT-09 is the smallest yes: score 2, pulls 5. IT-01 is the near miss: score 3, pulls 4. One more pull and it would flip. I like that the near miss is visible in the table as &lt;code&gt;no&lt;/code&gt;, not as a maybe. Maybes were the spreadsheet habit.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I almost added &lt;code&gt;TreatmentRate&lt;/code&gt; as a fifth metric on the item. That was wrong. A rate is an aggregate over many items, grouped by collection. The model names the row-level judgment. &lt;code&gt;collection_treatment_rate&lt;/code&gt; in &lt;code&gt;metrics.py&lt;/code&gt; does the grouping. If I had stored a rate on the row, every item in a collection would have carried a number that depends on its neighbors, and a one-line JSON diff would have looked like the object itself changed.&lt;/p&gt;

&lt;p&gt;The 50 percent flag lives in &lt;code&gt;report.py&lt;/code&gt; as &lt;code&gt;TREATMENT_CUTOFF&lt;/code&gt;. I want one cutoff for this experiment. A per-format cutoff is a real idea. It would be a property on Format and a change to the report, not a silent edit in the scorer. I have not done that change. Tapes and paper share the same score line on purpose, so I can see the tape collection lose because of scores, not because I wrote a special case for magnetic media.&lt;/p&gt;

&lt;h3&gt;
  
  
  Constraints the binder actually checks
&lt;/h3&gt;

&lt;p&gt;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 &lt;code&gt;raise&lt;/code&gt; exists.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;condition_score&lt;/code&gt; is an integer from 1 through 5.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;year_created&lt;/code&gt; is from 1800 through 2020. The window is wide enough for this sample and narrow enough to catch a year I typed as 19370.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;handling_count&lt;/code&gt; is zero or greater. &lt;code&gt;digitized&lt;/code&gt; must be a real boolean, not the string &lt;code&gt;"false"&lt;/code&gt;, which has bitten me in JSON more than once.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Unknown relationship targets fail in the binder when the JSON names a collection that is not in the file. Missing classes fail earlier, in &lt;code&gt;validate_ontology&lt;/code&gt;, before any JSON is read. Duplicate class names fail there too. Duplicate item ids fail in the binder. I split those layers after I had a single function that mixed "your YAML is nonsense" with "this volume is brittle" and the traceback was useless.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff2ohm8f48jffy9cxvbxo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff2ohm8f48jffy9cxvbxo.png" alt="Workflow" width="481" height="1933"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The flow chart is the same story with the exits drawn. A metric that names a missing property stops. A score outside 1 to 5 stops. Everything else is scored, grouped, and printed. The 50 percent line is a reporting cutoff. I drew it on the chart as a dashed line so I would remember it is mine.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I left out of the model
&lt;/h3&gt;

&lt;p&gt;Damage terms are the obvious omission. &lt;code&gt;foxing&lt;/code&gt;, &lt;code&gt;brittle&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;silvering&lt;/code&gt;, &lt;code&gt;sticky&lt;/code&gt;, and &lt;code&gt;shed&lt;/code&gt; live as a tuple in &lt;code&gt;ontology.py&lt;/code&gt;, and the scorer scans the note. I thought about a metric called &lt;code&gt;Damaged&lt;/code&gt; 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 &lt;code&gt;NeedsTreatment&lt;/code&gt;. IT-10 needs treatment and has an empty note. IT-01 has foxing and does not need treatment. Both facts stay visible. The note is extra, and I want it to stay extra.&lt;/p&gt;

&lt;p&gt;IT-09 is where the scout gets sloppy, and I should say so. The note is "sticky shed on the pack." The tuple contains both &lt;code&gt;sticky&lt;/code&gt; and &lt;code&gt;shed&lt;/code&gt;, so that one note counts as two word hits inside one item. &lt;code&gt;scout_notes&lt;/code&gt; in the report counts items, not words, so the headline number is still 6. If I had counted words, IT-09 would have inflated the total. I noticed that only after printing the note list. I did not "fix" the phrase. The phrase is what a label might actually say. The lesson I kept is that a substring scout double-counts compound damage terms. That is another reason it is not a metric.&lt;/p&gt;

&lt;p&gt;I also left location performance out. Let me be exact, because this is the kind of sentence I get wrong if I trust memory. The treatment ids are IT-04, IT-05, IT-08, IT-09, IT-10, and IT-12. IT-04 is in the vault. The other five are in the cold room. So the cold room holds most of the worn objects in this sample. That is a storage story I could tell. I did not add a location metric to celebrate it. Twelve rows is a toy. A room score on a toy becomes a costume, and the cold room is also where I chose to put the photographs and the tapes. The skew is partly my own staging.&lt;/p&gt;

&lt;h3&gt;
  
  
  The twelve rows, said in sentences
&lt;/h3&gt;

&lt;p&gt;I want the sample to be checkable without opening the JSON. These are the calls the tests lock.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;IT-01&lt;/strong&gt;, minutes, vault, paper, 1924, score 3, not digitized, 4 pulls, foxing. Treatment no, stable no, digitize yes, pull no. The watched row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IT-02&lt;/strong&gt;, minutes, vault, paper, 1948, score 4, digitized, 1 pull, empty note. Stable only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IT-03&lt;/strong&gt;, minutes, reading room, paper, 1962, score 5, digitized, 8 pulls. Stable only. The popular sound volume. Eight pulls do not create pull risk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IT-04&lt;/strong&gt;, minutes, vault, paper, 1911, score 1, not digitized, 2 pulls, brittle. Treatment yes, digitize yes, pull no. The only minutes volume in the treatment queue. That is why minutes land at 25 percent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IT-05&lt;/strong&gt;, photographs, cold room, 1937, score 2, not digitized, 3 pulls, curl. Treatment yes, digitize yes, pull no.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IT-06&lt;/strong&gt;, photographs, cold room, 1955, score 4, digitized, 1 pull. Stable only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IT-07&lt;/strong&gt;, photographs, reading room, 1978, score 5, not digitized, 6 pulls. Stable, and not next to scan, and not a pull risk. The holdout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IT-08&lt;/strong&gt;, photographs, cold room, 1941, score 1, not digitized, 0 pulls, silvering. Treatment yes, digitize yes. Nobody pulled it. Decay without popularity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IT-09&lt;/strong&gt;, tapes, cold room, 1972, score 2, not digitized, 5 pulls, sticky shed. Treatment yes, digitize yes, pull yes. Smallest pull-risk yes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IT-10&lt;/strong&gt;, tapes, cold room, 1980, score 2, not digitized, 2 pulls, empty note. Treatment yes because 2 is included. The boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IT-11&lt;/strong&gt;, tapes, vault, 1968, score 4, digitized, 1 pull. Stable only. The tape that keeps the collection from being 4 of 4.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IT-12&lt;/strong&gt;, tapes, cold room, 1975, score 1, not digitized, 7 pulls, oxide shed. Treatment yes, digitize yes, pull yes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Minutes are 1 of 4. Photographs are 2 of 4, which is exactly 50 percent, so they are flagged. Tapes are 3 of 4, 75 percent, so they sort first. &lt;code&gt;worst_collection&lt;/code&gt; is the first row after sorting by rate descending, then by id. With these numbers that is &lt;code&gt;COL-TAPE&lt;/code&gt;. If two collections ever tied, the id order would decide, and I would not pretend the tie was a judgment.&lt;/p&gt;

&lt;p&gt;Average score is 34 divided by 12, which prints as 2.83. I include it because a single average is the kind of number a dashboard loves, and on this sample it hides the split. 2.83 sounds like "a bit under the middle of a 1 to 5 scale." The rows say six objects are in the treatment queue and one is parked at 3. I print the average anyway, under the counts, so I can see it lose the argument.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Get Cooking
&lt;/h2&gt;

&lt;p&gt;This is the part I reread when I forget which check belongs to the file and which belongs to the row.&lt;/p&gt;

&lt;h3&gt;
  
  
  The metric block
&lt;/h3&gt;

&lt;p&gt;I wrote the judgments in YAML so the cutoffs would be visible without opening the scorer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NeedsTreatment&lt;/span&gt;
    &lt;span class="na"&gt;grain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Item&lt;/span&gt;
    &lt;span class="na"&gt;definition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Condition score is 2 or lower. Score 3 is watched, not queued.&lt;/span&gt;
    &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;at_most&lt;/span&gt;
      &lt;span class="na"&gt;property&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;condition_score&lt;/span&gt;
      &lt;span class="na"&gt;maximum&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Stable&lt;/span&gt;
    &lt;span class="na"&gt;grain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Item&lt;/span&gt;
    &lt;span class="na"&gt;definition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Condition score is 4 or higher.&lt;/span&gt;
    &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;at_least&lt;/span&gt;
      &lt;span class="na"&gt;property&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;condition_score&lt;/span&gt;
      &lt;span class="na"&gt;minimum&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DigitizeNext&lt;/span&gt;
    &lt;span class="na"&gt;grain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Item&lt;/span&gt;
    &lt;span class="na"&gt;definition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="s"&gt;No usable surrogate, and either the score is 3 or lower or the object&lt;/span&gt;
      &lt;span class="s"&gt;was made in 1940 or earlier.&lt;/span&gt;
    &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;undigitized_and&lt;/span&gt;
      &lt;span class="na"&gt;property_flag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;digitized&lt;/span&gt;
      &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;condition_score&lt;/span&gt;
      &lt;span class="na"&gt;score_maximum&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
      &lt;span class="na"&gt;year&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;year_created&lt;/span&gt;
      &lt;span class="na"&gt;year_maximum&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1940&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PullRisk&lt;/span&gt;
    &lt;span class="na"&gt;grain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Item&lt;/span&gt;
    &lt;span class="na"&gt;definition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Pulled at least five times in the sample year while the score is 3 or lower.&lt;/span&gt;
    &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;both_bounds&lt;/span&gt;
      &lt;span class="na"&gt;low_property&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;condition_score&lt;/span&gt;
      &lt;span class="na"&gt;low_maximum&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
      &lt;span class="na"&gt;high_property&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;handling_count&lt;/span&gt;
      &lt;span class="na"&gt;high_minimum&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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. The &lt;code&gt;&amp;gt;&lt;/code&gt; on &lt;code&gt;DigitizeNext&lt;/code&gt; is ordinary YAML folding. The validator does not read that string. If the string and the rule disagree, the rule wins, and I have only myself to blame. I try to make them say the same thing so future-me does not "correct" one and leave the other.&lt;/p&gt;

&lt;h3&gt;
  
  
  Refuse a metric that invents a property
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;validate_ontology&lt;/code&gt; walks each metric and checks the property names against the grain class. This is the whole point of loading the model before scoring.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_check_rule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MetricDef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;grain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ClassDef&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;rule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;
    &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;grain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;property_names&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;need&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prop_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;prop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prop_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;prop&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SchemaError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metric &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; on &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;grain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; names unknown property &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;prop&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;at_most&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;need&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;property&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;maximum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SchemaError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metric &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; needs an integer maximum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;at_least&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;need&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;property&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;minimum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SchemaError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metric &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; needs an integer minimum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;undigitized_and&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;need&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;property_flag&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;need&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;need&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;year&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score_maximum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SchemaError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metric &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; needs an integer score_maximum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;year_maximum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SchemaError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metric &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; needs an integer year_maximum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;both_bounds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;need&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low_property&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;need&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high_property&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low_maximum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SchemaError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metric &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; needs an integer low_maximum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high_minimum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SchemaError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metric &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; needs an integer high_minimum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SchemaError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metric &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; has unknown rule type &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;need&lt;/code&gt; is the line I care about. A string that is not on the grain is a schema error, even if the JSON happens to contain a column with that name. The model is the allow-list. The rows do not get to widen it.&lt;/p&gt;

&lt;p&gt;The test builds that failure on purpose. It loads the real YAML, appends a metric called &lt;code&gt;InsuredValue&lt;/code&gt; with property &lt;code&gt;appraisal_dollars&lt;/code&gt;, and expects &lt;code&gt;SchemaError&lt;/code&gt;. I used a money name so I would feel ridiculous if I ever wanted the test to pass. The match is the missing property, not the metric name. A renamed vanity metric with the same bad property still fails.&lt;/p&gt;

&lt;p&gt;Unknown rule types fail in the same function. I would rather crash on &lt;code&gt;type: python&lt;/code&gt; than grow an &lt;code&gt;eval&lt;/code&gt;. Four types are enough for this sample: a maximum, a minimum, an undigitized combination, and a pair of bounds. If a fifth judgment needs a fifth type, I want a code change and a test, not a string I parse at midnight.&lt;/p&gt;

&lt;h3&gt;
  
  
  How DigitizeNext actually decides
&lt;/h3&gt;

&lt;p&gt;The combination rule is the one I expect to misread, so here is the scorer without the other branches.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;undigitized_and&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;property_flag&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;weak_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score_maximum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;early_year&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;year&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;year_maximum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;weak_score&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;early_year&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;is not False&lt;/code&gt; check is stricter than &lt;code&gt;if row["digitized"]&lt;/code&gt;. I want a real boolean. If a future row stored &lt;code&gt;0&lt;/code&gt; or &lt;code&gt;"no"&lt;/code&gt;, I would rather throw when the binder runs than treat it as a clever false. The binder already requires &lt;code&gt;isinstance(..., bool)&lt;/code&gt; before this function sees the row. The scorer still asks for &lt;code&gt;False&lt;/code&gt; explicitly, because I have been burned by truthiness.&lt;/p&gt;

&lt;p&gt;Order matters. A digitized object returns false immediately. IT-04 is not digitized, score 1, year 1911, so both inner clauses are true and the result is true. IT-07 is not digitized, score 5, year 1978, so both inner clauses are false and the result is false. IT-01 is not digitized, score 3, year 1924. The score clause is true. The year clause is also true, because 1924 is at or before 1940. Either one would have been enough. I mention both so I do not later "simplify" the rule and claim IT-01 is next only because it is old. It is next because it is undigitized and weak. The age clause is redundant for IT-01 and load-bearing for an object I have not added yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  One brief, two clients
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;build_report&lt;/code&gt; is the only place that counts. It scores, groups, and picks the weak collections.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;TREATMENT_CUTOFF&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.50&lt;/span&gt;

&lt;span class="n"&gt;weak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rates&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;TREATMENT_CUTOFF&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comparison is &lt;code&gt;&amp;gt;=&lt;/code&gt;, not &lt;code&gt;&amp;gt;&lt;/code&gt;. Photographs at exactly 2 of 4 stay on the list. I did that on purpose. A cutoff I describe as "50 percent or higher" should include the equal case, or IT-05 and IT-08 become a story I round away. &lt;code&gt;worst_collection&lt;/code&gt; is &lt;code&gt;weak[0]&lt;/code&gt; after the rate sort. Tapes win that sort at 0.75.&lt;/p&gt;

&lt;p&gt;The CLI prints a fixed-width table and then four summary lines. The API returns the same dictionary with the row list removed, so the dashboard does not drag every note across the wire. I made that cut after looking at the payload. Twelve notes are nothing. The cut is still there, because the habit I want is "the brief function owns the numbers," not "the browser recomputes a rate from raw rows and drifts."&lt;/p&gt;

&lt;h3&gt;
  
  
  What a green test does not prove
&lt;/h3&gt;

&lt;p&gt;I want this distinction written down where the code is, not only at the end.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;YAML syntax.&lt;/strong&gt; If the indentation breaks, PyYAML raises before my validator runs. That only proves the file is YAML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model checks.&lt;/strong&gt; &lt;code&gt;validate_ontology&lt;/code&gt; proves one fact class, unique names, relationship targets that exist, supported cardinalities, and metric rules whose properties exist on the grain and whose thresholds are integers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Row checks.&lt;/strong&gt; &lt;code&gt;load_archive&lt;/code&gt; proves unique ids, relationship ids that exist in the sample, and the three windows on score, year, and pulls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rule execution.&lt;/strong&gt; &lt;code&gt;score_items&lt;/code&gt; applies the four rule types. It does not send SQL anywhere. There is no warehouse in this project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Business agreement.&lt;/strong&gt; Nothing in the tests asks a conservator whether 2 is the right line. The tests ask whether the code did what the YAML says. Those are different questions, and I have mixed them up often enough to say so here.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A valid file can still be a bad policy. If I change the treatment maximum from 2 to 4, the file still validates, and almost every row becomes a queue. The tests would fail only because I locked the current sample's counts. The lock is a snapshot of my choices, not a proof that the choices are wise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Setup
&lt;/h2&gt;

&lt;p&gt;Step by step details can be found at &lt;a href="https://github.com/aniket-work/FolioSchema" rel="noopener noreferrer"&gt;https://github.com/aniket-work/FolioSchema&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I used a Mac and a local virtualenv inside the project directory. From the clone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/aniket-work/FolioSchema.git
&lt;span class="nb"&gt;cd &lt;/span&gt;FolioSchema
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv venv
&lt;span class="nb"&gt;source &lt;/span&gt;venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;requirements.txt&lt;/code&gt; is PyYAML, FastAPI, Uvicorn, Matplotlib, and pytest. No database driver. No cloud SDK. If &lt;code&gt;python3&lt;/code&gt; on your machine is older than 3.11, the code may still run, but I did not test that, so I would not promise it. The import path is the project root. I run commands as &lt;code&gt;PYTHONPATH=.&lt;/code&gt; so I do not have to install the package into site-packages for a twelve-row experiment.&lt;/p&gt;

&lt;p&gt;The model file is &lt;code&gt;schema/archive.yaml&lt;/code&gt;. The rows are &lt;code&gt;data/archive.json&lt;/code&gt;. If you move either one, &lt;code&gt;main.py&lt;/code&gt; will not find them, because it resolves paths from the file location of &lt;code&gt;main.py&lt;/code&gt;, not from the shell's current directory. I did that after running the script from the wrong folder and debugging a missing file that was sitting right there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Run
&lt;/h2&gt;

&lt;p&gt;From the project root, with the virtualenv active:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PYTHONPATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; python main.py &lt;span class="nt"&gt;--chart&lt;/span&gt; images/treatment-chart.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the table I got, copied from that run.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ontology cedar_hollow_archive v0.3
fact grain: Item
metric NeedsTreatment on Item: condition_score &amp;lt;= 2
ID     COLLECTION   SCORE TREAT STABLE DIGIT  PULL NOTE
IT-01  COL-MINUTES      3    no     no   yes    no foxing along the gutter
IT-02  COL-MINUTES      4    no    yes    no    no -
IT-03  COL-MINUTES      5    no    yes    no    no -
IT-04  COL-MINUTES      1   yes     no   yes    no brittle leaves
IT-05  COL-PHOTO        2   yes     no   yes    no curl at the corners
IT-06  COL-PHOTO        4    no    yes    no    no -
IT-07  COL-PHOTO        5    no    yes    no    no -
IT-08  COL-PHOTO        1   yes     no   yes    no silvering across the sky
IT-09  COL-TAPE         2   yes     no   yes   yes sticky shed on the pack
IT-10  COL-TAPE         2   yes     no   yes    no -
IT-11  COL-TAPE         4    no    yes    no    no -
IT-12  COL-TAPE         1   yes     no   yes   yes oxide shed in the box
items=12 needs_treatment=6 stable=5 neither=1 treatment_rate=50.0%
digitize_next=7 pull_risk=2 avg_condition=2.83 scout_notes=6
collections at or above 50% NeedsTreatment: COL-TAPE (3/4), COL-PHOTO (2/4)
open first: COL-TAPE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I read it from the bottom. Open the tapes first. Then I read IT-11, the stable digitized tape, so I do not tell myself the whole carrier is doomed. Then I read IT-10, score 2 with an empty note, so I do not tell myself the queue is "whatever the label complained about." Then I read IT-07, the healthy undigitized photograph, so I do not tell myself digitization is a synonym for treatment.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnvbg1vosaxuc4fh2nv03.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnvbg1vosaxuc4fh2nv03.png" alt="Treatment chart" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The chart is the collection rates and nothing else. Minutes at 25 percent stay green, under the dashed line. Photographs at 50 and tapes at 75 go red. I colored the bars from the same cutoff the report uses. If those two ever diverge, the picture is lying, and I would rather the test fail than discover it in a screenshot.&lt;/p&gt;

&lt;p&gt;The tests are the same numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PYTHONPATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; python &lt;span class="nt"&gt;-m&lt;/span&gt; pytest &lt;span class="nt"&gt;-q&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six tests passed when I ran them for this write-up. One of them points &lt;code&gt;held_in&lt;/code&gt; at &lt;code&gt;COL-MISSING&lt;/code&gt; and expects &lt;code&gt;BindError&lt;/code&gt;. That is the row-level failure. The schema failure is the invented &lt;code&gt;appraisal_dollars&lt;/code&gt; metric. I keep both tests because I have, more than once, "fixed" a validator until it only caught the bug I was staring at.&lt;/p&gt;

&lt;p&gt;The HTTP path is optional. I still run it when I want to see that the dashboard is not a second scorer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PYTHONPATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; uvicorn api:app &lt;span class="nt"&gt;--port&lt;/span&gt; 8077
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;GET /api/brief&lt;/code&gt; returns &lt;code&gt;worst_collection&lt;/code&gt; of &lt;code&gt;COL-TAPE&lt;/code&gt;, &lt;code&gt;needs_treatment&lt;/code&gt; of 6, and &lt;code&gt;digitize_next&lt;/code&gt; of 7. The page at &lt;code&gt;/&lt;/code&gt; fetches that JSON and prints the three collection rates. If the page and the terminal disagree, I broke the "one brief" rule, and the fix belongs in &lt;code&gt;build_report&lt;/code&gt;, not in the HTML.&lt;/p&gt;

&lt;h3&gt;
  
  
  A deliberate bad model
&lt;/h3&gt;

&lt;p&gt;When I want to remember what the validator sounds like, I append a metric the grain cannot support and load the file. The test does this in a temp directory so the real YAML stays clean. The error text is the one &lt;code&gt;SchemaError&lt;/code&gt; formats:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;metric InsuredValue on Item names unknown property 'appraisal_dollars'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That message names the metric, the grain, and the bad property. I rewrote it once so it only said "invalid metric," and I immediately had to open the YAML to see which line. The longer message is the one I kept. After deleting the bad metric, &lt;code&gt;load_ontology&lt;/code&gt; succeeds again and the counts return to 6, 5, 1, 7, and 2. I like a round trip that ends on the same table. If the "fixed" file prints a new treatment count, I did not fix the model. I changed the policy and forgot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;The part I trust is the order of work. Write the question. Pick one grain. Name a dimension only when a row points at it. Keep observations in properties. Keep judgments in metrics. Put aggregates in the report. Refuse to score until the file validates. Then print a table small enough to argue with.&lt;/p&gt;

&lt;p&gt;The part I do not trust is the cutoffs. Score 2, score 4, year 1940, five pulls, 50 percent. I chose them so this sample would have a boundary, a gap, a holdout, and a winner. A different sample would make those numbers look arbitrary, because they are. The useful claim is not that Cedar Hollow's tapes are in trouble. The useful claim is that I can point at a line in YAML and say "this is the trouble I meant," and a second notebook cannot quietly mean something else.&lt;/p&gt;

&lt;p&gt;If I extend the experiment, the next change I actually want is a second fact, not a fifth metric. A treatment event, with a date and an outcome, would let me ask whether the queue shrank. That is a different grain. Bolting "treated" onto the item as a boolean would throw away the history the moment a second treatment happened. I have not built that file. The current one still cannot tell me whether IT-12 got worse after the seventh pull. It can tell me that, under rules I wrote down, IT-12 is in the treatment queue, the digitization queue, and the pull-risk list at the same time, and IT-07 is in none of them. That was the disagreement I was trying to make visible.&lt;/p&gt;

&lt;p&gt;The code, the chart, and the GIF are in &lt;a href="https://github.com/aniket-work/FolioSchema" rel="noopener noreferrer"&gt;https://github.com/aniket-work/FolioSchema&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclaimer
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Tags: python, yaml, opensource, data&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>yaml</category>
      <category>opensource</category>
      <category>data</category>
    </item>
    <item>
      <title>Building an Ontology Schema for a Small Supply Network</title>
      <dc:creator>Aniket Hingane</dc:creator>
      <pubDate>Fri, 16 Oct 2026 02:54:29 +0000</pubDate>
      <link>https://dev.to/exploredataaiml/building-an-ontology-schema-for-a-small-supply-network-4jh0</link>
      <guid>https://dev.to/exploredataaiml/building-an-ontology-schema-for-a-small-supply-network-4jh0</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7f4g4bg79upg6nf9sm27.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7f4g4bg79upg6nf9sm27.gif" alt="Cover" width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How I Defined Lanes, Shipments, and On-Time in One YAML Schema&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;schema/inland_network.yaml&lt;/code&gt;. The fact grain is &lt;code&gt;Shipment&lt;/code&gt;. The dimensions are &lt;code&gt;Node&lt;/code&gt;, &lt;code&gt;Lane&lt;/code&gt;, &lt;code&gt;Carrier&lt;/code&gt;, and &lt;code&gt;Sku&lt;/code&gt;. The metrics I was willing to name are &lt;code&gt;OnTime&lt;/code&gt;, &lt;code&gt;FillPercent&lt;/code&gt;, &lt;code&gt;DwellHours&lt;/code&gt;, and &lt;code&gt;LateHours&lt;/code&gt;. A metric that points at a property the grain does not have dies in &lt;code&gt;load_ontology&lt;/code&gt; with a &lt;code&gt;SchemaError&lt;/code&gt;. I did that on purpose. I have wasted afternoons chasing a column that three notebooks spelled three ways.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;LN-CHI-DAL&lt;/code&gt;, 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 &lt;a href="https://github.com/aniket-work/LaneSchema" rel="noopener noreferrer"&gt;https://github.com/aniket-work/LaneSchema&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;python main.py&lt;/code&gt; prints that table. FastAPI serves the same &lt;code&gt;build_report&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;LateHours&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;data/network.json&lt;/code&gt; 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 &lt;code&gt;eval&lt;/code&gt; at midnight.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's This Article About?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;You will see why &lt;code&gt;DwellHours&lt;/code&gt; is an alias of &lt;code&gt;dwell_hours&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://github.com/aniket-work/LaneSchema" rel="noopener noreferrer"&gt;https://github.com/aniket-work/LaneSchema&lt;/a&gt;. &lt;code&gt;README.md&lt;/code&gt; has the same images and the same counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;p&gt;I kept the list short. A schema PoC that needs a graph database before the first table is a PoC I will not rerun.&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fouud8llsbbpy1fn1vjrl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fouud8llsbbpy1fn1vjrl.png" alt="Architecture" width="800" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Why Read It?
&lt;/h2&gt;

&lt;p&gt;Read it if you have a folder of notebooks that each define the same operational word. This experiment fails in an obvious way. If &lt;code&gt;OnTime&lt;/code&gt; starts reading a property named &lt;code&gt;revenue&lt;/code&gt;, &lt;code&gt;test_metric_cannot_name_a_missing_property&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;A few choices are the ones I would repeat.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pick the grain before the dimensions.&lt;/strong&gt; I wrote &lt;code&gt;Shipment&lt;/code&gt; as the only fact. Node and lane exist because a shipment points at them, not because a diagram looked empty without them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put judgments in metrics and observations in properties.&lt;/strong&gt; &lt;code&gt;actual_hours&lt;/code&gt; is an observation. &lt;code&gt;OnTime&lt;/code&gt; is a judgment with a grace constant. Mixing those in one column is how my old sheets drifted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail the file, not the row, when the schema is wrong.&lt;/strong&gt; An unknown property is a schema bug. A late shipment is a data fact. Those should not share an error path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep one brief function.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alias a measure you are tempted to rename.&lt;/strong&gt; &lt;code&gt;DwellHours&lt;/code&gt; points at &lt;code&gt;dwell_hours&lt;/code&gt;. I got tired of "dock time" and "wait hours" showing up as silent synonyms.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Design
&lt;/h2&gt;

&lt;p&gt;Before classes, I wrote the question and the cutoff on paper.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Question.&lt;/strong&gt; Which lanes have an on-time rate under 50 percent?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grain.&lt;/strong&gt; One shipment, meaning one trailer movement on one lane.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On time.&lt;/strong&gt; &lt;code&gt;actual_hours &amp;lt;= planned_hours + 2&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Also show.&lt;/strong&gt; Fill as a percent, dwell in hours, and hours past the grace window.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  One fact, four dimensions
&lt;/h3&gt;

&lt;p&gt;A semantic model gets muddy when two things both claim to be the row. I made &lt;code&gt;Shipment&lt;/code&gt; the only &lt;code&gt;kind: fact&lt;/code&gt;. Everything else is a dimension.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Node&lt;/strong&gt; is a place a trailer can leave or enter. Roles in the sample are port, dc, and store. The key is &lt;code&gt;node_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lane&lt;/strong&gt; is a directed hop. It has a mode, truck or rail, and two relationships, &lt;code&gt;origin&lt;/code&gt; and &lt;code&gt;destination&lt;/code&gt;, both many-to-one to Node.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Carrier&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sku&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The shipment relationships are &lt;code&gt;travels&lt;/code&gt; to Lane, &lt;code&gt;hauled_by&lt;/code&gt; to Carrier, and &lt;code&gt;contains&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frijd28lxsfrzk635x7kn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frijd28lxsfrzk635x7kn.png" alt="Sequence" width="799" height="345"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Properties are what I measured
&lt;/h3&gt;

&lt;p&gt;On &lt;code&gt;Shipment&lt;/code&gt; the properties are the things I could imagine reading off a bill or a dock clock.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;planned_hours&lt;/code&gt; and &lt;code&gt;actual_hours&lt;/code&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dwell_hours&lt;/code&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;units_ordered&lt;/code&gt; and &lt;code&gt;units_shipped&lt;/code&gt; are counts. Fill is not stored.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;note&lt;/code&gt; is free text. The ontology does not interpret it. The report looks for three words after scoring.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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 &lt;code&gt;actual_hours&lt;/code&gt; "to make the chart more dramatic." The chart got less dramatic. The metric got clearer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Metrics are the judgments
&lt;/h3&gt;

&lt;p&gt;Four metrics, all on &lt;code&gt;Shipment&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;OnTime&lt;/strong&gt; uses rule type &lt;code&gt;within_grace&lt;/code&gt;. Actual, planned, grace hours 2. The result is a boolean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FillPercent&lt;/strong&gt; uses &lt;code&gt;percent_ratio&lt;/code&gt;. Numerator &lt;code&gt;units_shipped&lt;/code&gt;, denominator &lt;code&gt;units_ordered&lt;/code&gt;, times 100.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DwellHours&lt;/strong&gt; uses &lt;code&gt;alias&lt;/code&gt; on &lt;code&gt;dwell_hours&lt;/code&gt;. Same number, one name the brief is allowed to print.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LateHours&lt;/strong&gt; uses &lt;code&gt;lateness&lt;/code&gt;. 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I almost added &lt;code&gt;OnTimeRate&lt;/code&gt; 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. &lt;code&gt;lane_on_time&lt;/code&gt; in &lt;code&gt;metrics.py&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Constraints the binder actually checks
&lt;/h3&gt;

&lt;p&gt;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 &lt;code&gt;raise&lt;/code&gt; exists.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;units_shipped&lt;/code&gt; must be less than or equal to &lt;code&gt;units_ordered&lt;/code&gt;. Over-ship would have made fill exceed 100, and I did not want a second story about extras.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;units_ordered&lt;/code&gt; must be greater than zero so the ratio has a denominator.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Unknown relationship targets fail earlier, in &lt;code&gt;validate_ontology&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F22r1u8d84szom5edk8ks.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F22r1u8d84szom5edk8ks.png" alt="Workflow" width="459" height="1870"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;build_report&lt;/code&gt;, not a class in the ontology. I drew it on the chart as a dashed line so I would remember it is mine.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I left out of the schema
&lt;/h3&gt;

&lt;p&gt;Damage terms are the obvious omission. &lt;code&gt;crush&lt;/code&gt;, &lt;code&gt;wet&lt;/code&gt;, and &lt;code&gt;leak&lt;/code&gt; live as a tuple in &lt;code&gt;ontology.py&lt;/code&gt; and the scorer scans the note. I thought about a metric called &lt;code&gt;Damaged&lt;/code&gt; 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 &lt;code&gt;OnTime&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;REDLINE&lt;/code&gt;. The on-time rows are all &lt;code&gt;NORTHHAUL&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Get Cooking
&lt;/h2&gt;

&lt;p&gt;This is the part I reread when I forget which check belongs to the file and which belongs to the row.&lt;/p&gt;

&lt;h3&gt;
  
  
  The metric block
&lt;/h3&gt;

&lt;p&gt;I wrote the judgments in YAML so the grace constant would be visible without opening the scorer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OnTime&lt;/span&gt;
    &lt;span class="na"&gt;grain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Shipment&lt;/span&gt;
    &lt;span class="na"&gt;definition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Arrived no later than the plan plus two hours.&lt;/span&gt;
    &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;within_grace&lt;/span&gt;
      &lt;span class="na"&gt;actual&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actual_hours&lt;/span&gt;
      &lt;span class="na"&gt;planned&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;planned_hours&lt;/span&gt;
      &lt;span class="na"&gt;grace_hours&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;FillPercent&lt;/span&gt;
    &lt;span class="na"&gt;grain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Shipment&lt;/span&gt;
    &lt;span class="na"&gt;definition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Units shipped divided by units ordered, as a percent.&lt;/span&gt;
    &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;percent_ratio&lt;/span&gt;
      &lt;span class="na"&gt;numerator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;units_shipped&lt;/span&gt;
      &lt;span class="na"&gt;denominator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;units_ordered&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DwellHours&lt;/span&gt;
    &lt;span class="na"&gt;grain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Shipment&lt;/span&gt;
    &lt;span class="na"&gt;definition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Hours the trailer sat at the origin dock. Alias of dwell_hours so a second name cannot drift.&lt;/span&gt;
    &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;alias&lt;/span&gt;
      &lt;span class="na"&gt;property&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dwell_hours&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Refuse a metric that invents a property
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;validate_ontology&lt;/code&gt; walks each metric and checks the property names against the grain class. This is the whole point of compiling the schema before scoring.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_check_rule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MetricDef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;grain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ClassDef&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;rule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;
    &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;RULE_TYPES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SchemaError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; has unknown rule type &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;props&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;grain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;property_names&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alias&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;_require_prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;property&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;percent_ratio&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;_require_prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;numerator&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="nf"&gt;_require_prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;denominator&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;within_grace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lateness&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}:&lt;/span&gt;
        &lt;span class="nf"&gt;_require_prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;actual&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="nf"&gt;_require_prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;planned&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grace_hours&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SchemaError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; grace_hours must be &amp;gt;= 0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_require_prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SchemaError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; references unknown property &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tested this with a metric called &lt;code&gt;GhostRate&lt;/code&gt; that aliases &lt;code&gt;revenue&lt;/code&gt;. The load raises &lt;code&gt;SchemaError&lt;/code&gt; and the message contains &lt;code&gt;unknown property revenue&lt;/code&gt;. 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.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Score only what the metric declared
&lt;/h3&gt;

&lt;p&gt;The scorer does not know the word "shipment" beyond the dict of values. It knows the rule type.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;score_metric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MetricDef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ShipmentRow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;rule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;
    &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alias&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;property&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;percent_ratio&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;denom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;denominator&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;100.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;numerator&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;denom&lt;/span&gt;
    &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;actual&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
    &lt;span class="n"&gt;planned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;planned&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]])&lt;/span&gt;
    &lt;span class="n"&gt;grace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grace_hours&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;within_grace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;planned&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;grace&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lateness&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;planned&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;grace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown rule &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;lt;=&lt;/code&gt; on the grace comparison is the line I got wrong once. I had written &lt;code&gt;&amp;lt;&lt;/code&gt;, and SH-05, which lands exactly on planned plus two, flipped to late. The test &lt;code&gt;test_grace_boundary_is_on_time&lt;/code&gt; is that argument written down. Late hours uses &lt;code&gt;max(0, actual - planned - grace)&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bind the lane before you trust the id
&lt;/h3&gt;

&lt;p&gt;A shipment string that says &lt;code&gt;travels: LN-CHI-DAL&lt;/code&gt; is not a lane until the binder finds that id and then finds both nodes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;units_ordered&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SchemaError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ident&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; units_ordered must be &amp;gt; 0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;units_shipped&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;units_ordered&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SchemaError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ident&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; units_shipped exceeds units_ordered&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;lane&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;indexes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Lane&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;travels&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lane&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SchemaError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ident&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; travels unknown lane &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;travels&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h3&gt;
  
  
  Group by lane after the row score
&lt;/h3&gt;

&lt;p&gt;The weak-lane question is not a metric. It is a fold.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lane_on_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ontology&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Ontology&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ShipmentRow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;flag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;score_metric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ontology&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;metric_named&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OnTime&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lane&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lane_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;lane_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;flag&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lane_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lane_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shipments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on_time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;flag&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on_time_rate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on_time_rate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lane_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;build_report&lt;/code&gt; then keeps lanes whose rate is under 0.5. On this file that list is exactly &lt;code&gt;["LN-CHI-DAL"]&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;Fill under 95 percent is another fold, &lt;code&gt;short_fills&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dwell stays on the origin
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;origin_dwell&lt;/code&gt; averages &lt;code&gt;DwellHours&lt;/code&gt; 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 &lt;code&gt;LN-CHI-DAL&lt;/code&gt; or &lt;code&gt;LN-CHI-MSP&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  A shipment, field by field
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;LN-CHI-DAL&lt;/code&gt;, hauled by Redline, contains soap. The binder does not trust the lane string. It looks up &lt;code&gt;LN-CHI-DAL&lt;/code&gt;, finds mode truck, origin &lt;code&gt;DC-CHI&lt;/code&gt;, destination &lt;code&gt;DC-DAL&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;Then the four metrics, and only those four.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;OnTime&lt;/strong&gt; compares 23 with 18 plus 2. 23 is greater than 20, so the flag is false.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LateHours&lt;/strong&gt; is 23 minus 18 minus 2, which is 3.0. The same grace, the same two properties, a remainder instead of a boolean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FillPercent&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DwellHours&lt;/strong&gt; copies 6.2. It does not add dwell to the transit hours, because I wrote the property definitions to keep those clocks apart.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The note is scanned after that. &lt;code&gt;crush&lt;/code&gt; hits. The row is late, short, and marked. SH-09, two lines below it in the file, is also on &lt;code&gt;LN-CHI-DAL&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;hauled_by&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Setup
&lt;/h2&gt;

&lt;p&gt;Step by step details can be found at &lt;a href="https://github.com/aniket-work/LaneSchema" rel="noopener noreferrer"&gt;https://github.com/aniket-work/LaneSchema&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/aniket-work/LaneSchema.git
&lt;span class="nb"&gt;cd &lt;/span&gt;LaneSchema
python &lt;span class="nt"&gt;-m&lt;/span&gt; venv venv
&lt;span class="nb"&gt;source &lt;/span&gt;venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
pytest
python main.py &lt;span class="nt"&gt;--print-schema&lt;/span&gt; &lt;span class="nt"&gt;--chart&lt;/span&gt; images/ontime-chart.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The dashboard is optional.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uvicorn app:app &lt;span class="nt"&gt;--port&lt;/span&gt; 8000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open the local page and press the button. It calls &lt;code&gt;GET /api/brief&lt;/code&gt;, which calls &lt;code&gt;run&lt;/code&gt;, which calls &lt;code&gt;build_report&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Run
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--print-schema&lt;/code&gt; 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 &lt;code&gt;OnTime&lt;/code&gt;, I am looking at the wrong file.&lt;/p&gt;

&lt;p&gt;The table for the sample, abbreviated to the rows I keep talking about, looks like this when I run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lane rates, worst first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;LN-CHI-DAL&lt;/code&gt; is 1 of 4, 25 percent. Truck, Chicago to Dallas.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LN-CHI-MSP&lt;/code&gt; is 2 of 3, 66.7 percent. Truck, Chicago to Minneapolis.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LN-DAL-AUS&lt;/code&gt; is 2 of 3, 66.7 percent. Truck, Dallas to Austin.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LN-LA-CHI&lt;/code&gt; is 3 of 4, 75 percent. Rail, Los Angeles to Chicago.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;The GIF at the top types &lt;code&gt;python main.py --chart images/ontime-chart.png&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cases I had to pin with tests
&lt;/h2&gt;

&lt;p&gt;I do not add a test for every row. I add one when a rule exists only because I got burned.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The sample counts stay 14 shipments, 8 on time, 6 late, weak lanes exactly &lt;code&gt;LN-CHI-DAL&lt;/code&gt;, and 3 damage notes. The short-fill set stays &lt;code&gt;SH-02&lt;/code&gt;, &lt;code&gt;SH-04&lt;/code&gt;, &lt;code&gt;SH-06&lt;/code&gt;, &lt;code&gt;SH-08&lt;/code&gt;. When I add a fifteenth shipment I have to edit this test on purpose.&lt;/li&gt;
&lt;li&gt;SH-05 is on time and late hours are 0. That protects the inclusive grace.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;A metric that aliases &lt;code&gt;revenue&lt;/code&gt; raises &lt;code&gt;SchemaError&lt;/code&gt;. That protects the compile step.&lt;/li&gt;
&lt;li&gt;A relationship aimed at a class named &lt;code&gt;Warehouse&lt;/code&gt; raises before scoring. That protects me from a diagram I only drew on paper.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the experiment is thin
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The keyword note is thin in a different way. "Crushed" would slip past &lt;code&gt;crush&lt;/code&gt; if I had stemmed nothing and also forgotten the substring. &lt;code&gt;crush&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;OnTime&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would add if I keep going
&lt;/h2&gt;

&lt;p&gt;These are notes to myself.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A per-lane grace as a property on &lt;code&gt;Lane&lt;/code&gt;, 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 &lt;code&gt;OnTime&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A line-level grain for mixed trailers, which means fill moves off &lt;code&gt;Shipment&lt;/code&gt; and the current ratio test has to be rewritten on purpose.&lt;/li&gt;
&lt;li&gt;Word-boundary matching on the damage list, plus a note that says the damaged cases were removed before departure.&lt;/li&gt;
&lt;li&gt;A held-out JSON file of shipments I did not stare at while picking 50 percent and 95 percent.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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 &lt;code&gt;&amp;lt;&lt;/code&gt; versus &lt;code&gt;&amp;lt;=&lt;/code&gt; bug. I am keeping them in the foreground.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I checked the pictures
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;images/&lt;/code&gt; 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 &lt;code&gt;SchemaError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fancydbnk9h9beoyeo8a0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fancydbnk9h9beoyeo8a0.png" alt="Title diagram" width="800" height="655"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://github.com/aniket-work/LaneSchema" rel="noopener noreferrer"&gt;https://github.com/aniket-work/LaneSchema&lt;/a&gt; if you want the same fourteen shipments I used while writing this.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclaimer
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Tags: python, ontology, supplychain, opensource&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>ontology</category>
      <category>supplychain</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building a Semantic Model for Recycling Contamination Triage</title>
      <dc:creator>Aniket Hingane</dc:creator>
      <pubDate>Thu, 15 Oct 2026 03:43:07 +0000</pubDate>
      <link>https://dev.to/exploredataaiml/building-a-local-multi-agent-shift-desk-for-recycling-contamination-3i02</link>
      <guid>https://dev.to/exploredataaiml/building-a-local-multi-agent-shift-desk-for-recycling-contamination-3i02</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5s8uc6s58ojxxgykxxn9.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5s8uc6s58ojxxgykxxn9.gif" alt="Cover" width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How I Put Commodity Measures Under a Local Multi-Agent Shift Desk&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;LD-106 looked acceptable if you only read the numbers. HDPE natural, 7,340 pounds, residue at 3.1 percent, moisture just over 1 percent. The spot-check note ruined that story: a battery pack sitting in the third gaylord. I built ResidueDesk-AI, a personal experiment, so a laptop at a fictional scale house would quarantine that load instead of waving it onto a tip floor because the residue percent was low. The full source is at &lt;a href="https://github.com/aniket-work/ResidueDesk-AI" rel="noopener noreferrer"&gt;https://github.com/aniket-work/ResidueDesk-AI&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The PoC is a semantic model with a local coordinator on top. The model names three entities, Load, Commodity, and Supplier, and three measures on Load: &lt;code&gt;NetWeightLb&lt;/code&gt;, &lt;code&gt;ResiduePercent&lt;/code&gt;, and &lt;code&gt;MoisturePercent&lt;/code&gt;. Commodity metric rows bind accept, resort, and moisture caps to each material. Five steps then read that projection: intake, residue scout, commodity grader, line router, and shift briefer. Nothing calls a model API. &lt;code&gt;python main.py&lt;/code&gt; prints a table and a shift brief that lists reject and divert before the clean accepts. On the ten sample tickets the brief lands at 2 accept, 4 resort, 3 divert, 1 reject, average residue 8.36 percent.&lt;/p&gt;

&lt;p&gt;I kept the project small on purpose. Pydantic checks the ticket, the ticket layer, before any step runs. &lt;code&gt;semantic.py&lt;/code&gt; lifts that row onto the semantic layer, and the agents only consume the projection. FastAPI serves the same function the CLI calls, plus a plain dashboard. From my experience, that split is what makes an agent demo debuggable. When LD-108 flips from accept to resort, the trace says &lt;code&gt;moisture_cap&lt;/code&gt; and I can point at the mixed-paper metric row instead of guessing which prompt drifted.&lt;/p&gt;

&lt;p&gt;This write-up is my experiment, not a plant procedure. I have not bolted it to a live scale, and the percents are not a mill contract. I think the useful part is the layering: a ticket, a semantic model that names the measures, and a coordinator that is not allowed to invent a second definition of residue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;I got annoyed at a pattern in agent demos I kept cloning. A coordinator, a handful of specialists, a daily brief, all of it running on a laptop with open-source pieces. The demos were usually about calendars, inboxes, or research summaries. Those are fine toys. They are a poor fit for the question I actually wanted to poke at, which is what happens when one bad sentence in a note should overrule a clean numeric field.&lt;/p&gt;

&lt;p&gt;Recycling contamination gave me that question without inventing a company story around it. A materials recovery facility lives on inbound loads. Each load shows up as a ticket: commodity, net weight, a residue percent from a spot check, a moisture reading, a supplier id, a timestamp, and a note written by whoever looked in the bale. That ticket is the bottom layer. I did not want the agents to be the place where "residue" got redefined in five slightly different ways. So I wrote a semantic model first, then pointed the agents at it.&lt;/p&gt;

&lt;p&gt;The battery example is the one I started with. Residue at 3.1 percent is inside the accept band I later set for HDPE. If the grader ran alone, LD-106 would go to &lt;code&gt;tip_floor_primary&lt;/code&gt;. That felt wrong the moment I typed the note. Batteries, propane, aerosols, lithium packs, and syringes are the terms I put on a prohibited list. The scout finds them. The router stops there. I put it this way because I have watched too many pipelines average a text field into a score and then lose the one token that mattered.&lt;/p&gt;

&lt;p&gt;Moisture was the second itch. OCC at 3.2 percent residue and 8 percent moisture (LD-101) should accept. Mixed paper at 6.2 percent residue looks acceptable against the 8 percent accept cap I chose, until moisture is 19.4 percent (LD-108). Wet fiber blinds the story I was trying to model, so I worsen the call by one step. Accept becomes resort. I did not worsen divert into some fifth bucket. There is no bucket past the bunker in this PoC.&lt;/p&gt;

&lt;p&gt;I also wanted the output to look like a shift handoff, not a chat transcript. The briefer sorts reject, then divert, then resort, then accept. A person scanning a laptop at 6 a.m. should see LD-106 before they see the pretty PET load. In my opinion that ordering is the product. The agents are just how I kept the reasons separate enough to test.&lt;/p&gt;

&lt;p&gt;The tip floor still wants a call. Accept it on the primary line, send it to a resort bay, divert it to a residue bunker, or quarantine it. I am not a floor lead. I built a sample facility I named North Wharf MRF and I fed it tickets I wrote by hand. The semantic layer sits between those tickets and that call. Load is the fact grain, keyed by &lt;code&gt;load_id&lt;/code&gt;. Commodity and Supplier hang off it, many tickets to one commodity, many tickets to one supplier code. The measures I was willing to define are &lt;code&gt;NetWeightLb&lt;/code&gt;, &lt;code&gt;ResiduePercent&lt;/code&gt;, and &lt;code&gt;MoisturePercent&lt;/code&gt;. Caps such as OCC accept-through-5 live on the commodity metric row, not inside a prompt.&lt;/p&gt;

&lt;p&gt;None of this is work I did for an employer. The thresholds moved while I was writing tests. The first OCC accept cap I tried was 8 percent, and the sample set got boring because too many tickets accepted. I tightened OCC to 5 percent on the commodity metric so LD-102 at 9.4 percent would land in resort and I would have something to assert. That is an experimental choice. I say it plainly so nobody treats the semantic model as a specification from a mill.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's This Article About?
&lt;/h2&gt;

&lt;p&gt;I walk through ResidueDesk-AI as three layers. The ticket layer is the JSON row. The semantic layer is &lt;code&gt;semantic.py&lt;/code&gt;: entities, measures, relationships, and commodity metrics. The decision layer is the five steps that read a projection and emit a disposition. Then the CLI, the chart, and the small FastAPI dashboard, all calling the same &lt;code&gt;run_shift&lt;/code&gt; function.&lt;/p&gt;

&lt;p&gt;You will see the semantic model in code, then the scout, the router, and the decision function, with notes under each block. Setup is the clone path on GitHub. The run section shows what the ten-ticket sample actually prints, including the priority brief.&lt;/p&gt;

&lt;p&gt;I also write down the cases that surprised me once the tests existed: ceramic in a glass load with low residue, a 280 pound OCC ticket that was otherwise clean, and a film load at 28 percent residue that had no prohibited words and still had to leave the primary line. Those cases are why I do not trust a single function with a pile of &lt;code&gt;if&lt;/code&gt; statements hiding in one module. I tried that for an hour. The trace became a paragraph and the tests got worse.&lt;/p&gt;

&lt;p&gt;If you want the repo without the narrative, start at &lt;a href="https://github.com/aniket-work/ResidueDesk-AI" rel="noopener noreferrer"&gt;https://github.com/aniket-work/ResidueDesk-AI&lt;/a&gt;. &lt;code&gt;README.md&lt;/code&gt; has the same images and the same counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;p&gt;I kept the dependency list short. A PoC that needs three services before the first table is a PoC I will not rerun next month.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Python 3.11&lt;/strong&gt; is the runtime. I develop on that minor version and I did not chase newer syntax.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pydantic v2&lt;/strong&gt; validates each ticket on the physical layer. A negative residue percent fails before the semantic projection runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A semantic model in &lt;code&gt;semantic.py&lt;/code&gt;&lt;/strong&gt; names entities, measures, relationships, and commodity metrics. The grader reads those metrics. It does not keep a private copy of the caps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The coordinator is ordinary Python&lt;/strong&gt;. I wanted the call order, project then scout then grade, to be a function I can read top to bottom.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FastAPI&lt;/strong&gt; exposes &lt;code&gt;/api/triage/sample&lt;/code&gt; and serves &lt;code&gt;static/dashboard.html&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rich&lt;/strong&gt; prints the CLI table. I still judge these experiments by whether the terminal looks like a tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Matplotlib&lt;/strong&gt; writes the disposition chart. Four bars. I wanted a picture of the batch, not a dashboard product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pytest&lt;/strong&gt; locks the overrides and checks that a projection exposes &lt;code&gt;ResiduePercent&lt;/code&gt; bound to &lt;code&gt;SPEC-OCC-01&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I skipped a vector database. Six commodity cards do not need embeddings. I skipped a hosted language model. The desk I wanted, a local brief before the shift runs away, can sit on a semantic layer when the inputs are already structured and the overrides are keywords. If I add a language model later, I want it rewriting the brief line into plainer words, with the disposition enum and the measure definitions left untouched underneath.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcgmmed8emucu1l93j8wb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcgmmed8emucu1l93j8wb.png" alt="Architecture" width="800" height="354"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The picture matches the runtime. &lt;code&gt;main.py&lt;/code&gt; and the dashboard both call &lt;code&gt;coordinator.py&lt;/code&gt;. That coordinator calls &lt;code&gt;project_load&lt;/code&gt; in &lt;code&gt;semantic.py&lt;/code&gt; before the specialists run. Narrative cards still live in &lt;code&gt;rules.py&lt;/code&gt;, and the numeric caps are built from &lt;code&gt;COMMODITY_METRICS&lt;/code&gt; so the paragraph and the measure cannot quietly diverge. Analytics reads the finished report and writes &lt;code&gt;images/disposition-chart.png&lt;/code&gt;. Sample tickets stay in &lt;code&gt;sample_loads.json&lt;/code&gt; so the article, the tests, and the GIF describe one run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Read It?
&lt;/h2&gt;

&lt;p&gt;Read it if you are tired of agent write-ups where the only input is a sentence and the only output is another sentence. This one fails in obvious ways. If LD-106 accepts, the test named &lt;code&gt;test_battery_note_rejects&lt;/code&gt; fails. That kind of failure is the feedback I want while I am still changing constants.&lt;/p&gt;

&lt;p&gt;A few choices transferred cleanly from other personal experiments, and a few did not.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Separate the override from the grade.&lt;/strong&gt; Residue bands and prohibited notes want different tests. Mixing them into one score made LD-106 look "mostly fine" in an early printout, which is the bug I was trying to kill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sort the brief by severity.&lt;/strong&gt; Input order is ticket order. Handoff order is danger order. I sort with a small map: reject, divert, resort, accept, then load id.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep one function for the decision.&lt;/strong&gt; The CLI and the API do not each grow a copy of the rules. When I fixed underweight tickets, both clients picked it up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store the metric on the commodity, with a card id.&lt;/strong&gt; &lt;code&gt;SPEC-OCC-01&lt;/code&gt; shows up on the projection and on the decision. When I change the accept cap I edit the semantic model, and the grader's dict is rebuilt from that row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Project once per ticket.&lt;/strong&gt; &lt;code&gt;decide_load&lt;/code&gt; writes a &lt;code&gt;semantic:&lt;/code&gt; trace line with the measure names and the card id. If that line and the grader disagree, I broke the layering.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I do not think this replaces a person on a floor. I think it is a decent skeleton for a local briefing tool when the raw record is a form plus a note. The GitHub project includes the tests and the GIF so you can see the same 8.36 percent average I am quoting here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Design
&lt;/h2&gt;

&lt;p&gt;Before functions, I wrote the four outcomes on paper.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Accept&lt;/strong&gt; goes to &lt;code&gt;tip_floor_primary&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resort&lt;/strong&gt; goes to &lt;code&gt;qc_bay_2&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Divert&lt;/strong&gt; goes to &lt;code&gt;residue_bunker&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reject&lt;/strong&gt; goes to &lt;code&gt;quarantine_pad&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those line names are labels in a dict, not equipment I own. They keep the brief from saying only "bad" and "fine."&lt;/p&gt;

&lt;h3&gt;
  
  
  Three layers
&lt;/h3&gt;

&lt;p&gt;I got tired of reading agent code where the same percent lived in a prompt, a constant, and a comment, and all three had drifted. ResidueDesk-AI keeps three layers, and I try not to let a fact skip one.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ticket layer.&lt;/strong&gt; &lt;code&gt;sample_loads.json&lt;/code&gt; and the &lt;code&gt;InboundLoad&lt;/code&gt; model. This is what a scale export would look like. Pydantic lives here. Agents are not supposed to invent fields at this layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semantic layer.&lt;/strong&gt; &lt;code&gt;semantic.py&lt;/code&gt;. Entities are Load, Commodity, and Supplier. Measures are &lt;code&gt;NetWeightLb&lt;/code&gt;, &lt;code&gt;ResiduePercent&lt;/code&gt;, and &lt;code&gt;MoisturePercent&lt;/code&gt;, each with a one-sentence definition. Relationships are Load-to-Commodity and Load-to-Supplier, both many-to-one. &lt;code&gt;COMMODITY_METRICS&lt;/code&gt; is the metric binding: for a commodity, the accept cap, the resort cap, the moisture cap, whether it is fiber, and the card id. &lt;code&gt;project_load&lt;/code&gt; is the only function that lifts a ticket onto that model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decision layer.&lt;/strong&gt; Intake, scout, grader, router, briefer. The grader bands &lt;code&gt;ResiduePercent&lt;/code&gt; using the metric row for that commodity. The router may override the band. It may not rewrite what residue means.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The spec-card paragraphs in &lt;code&gt;rules.py&lt;/code&gt; are the human reading of a card id. The floats they describe are generated from &lt;code&gt;COMMODITY_METRICS&lt;/code&gt;. I did that after I edited a comment and forgot the dict. A comment can lie. A dict built from the model has to be updated in one place.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdvf4yzxdv4merzfo9qo1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdvf4yzxdv4merzfo9qo1.png" alt="Sequence" width="799" height="303"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The ticket
&lt;/h3&gt;

&lt;p&gt;An inbound load has eight fields. &lt;code&gt;load_id&lt;/code&gt; and &lt;code&gt;supplier_id&lt;/code&gt; are strings. &lt;code&gt;commodity&lt;/code&gt; is an enum: OCC, mixed paper, PET, HDPE, glass, LDPE film. &lt;code&gt;net_weight_lb&lt;/code&gt; is an int capped at 80,000 because I wanted a ridiculous ticket to fail validation. &lt;code&gt;residue_percent&lt;/code&gt; and &lt;code&gt;moisture_percent&lt;/code&gt; are floats from 0 to 100. &lt;code&gt;spot_check_notes&lt;/code&gt; is free text. &lt;code&gt;received_at&lt;/code&gt; is a string timestamp. I did not parse it into a datetime. For this experiment the scout does not care about the hour, and I did not want timezone bugs in a PoC about residue.&lt;/p&gt;

&lt;h3&gt;
  
  
  Intake
&lt;/h3&gt;

&lt;p&gt;Intake looks for three flags: weight under 400 pounds, blank supplier, blank timestamp. The 400 pound line is mine. LD-110 is a 280 pound OCC ticket with residue at 2.8 percent, which is in grade. I still send it to the resort bay if it would otherwise accept, because a partial bale in my sample set was usually a broken ticket, not a heroic small load. If the load is already divert or reject, I do not pile an extra disposition change on top. The flag still appears in the trace when the weight is low. The disposition change only fires on an accept.&lt;/p&gt;

&lt;h3&gt;
  
  
  Residue scout
&lt;/h3&gt;

&lt;p&gt;The scout lowercases the note and looks for &lt;code&gt;battery&lt;/code&gt;, &lt;code&gt;propane&lt;/code&gt;, &lt;code&gt;syringe&lt;/code&gt;, &lt;code&gt;aerosol&lt;/code&gt;, and &lt;code&gt;lithium&lt;/code&gt;. Any hit is a list of terms. I did not stem words. "batteries" would slip through, and I left that hole on purpose so I would remember how dumb keyword lists are. The sample note says "battery pack," so the test passes. A future note that says "batteries" would not, and I would rather see that miss in a test than pretend the list is complete.&lt;/p&gt;

&lt;h3&gt;
  
  
  Commodity grader
&lt;/h3&gt;

&lt;p&gt;The grader only answers "which band." It does not pick the line. That was a deliberate split after I tangled them.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Residue at or below &lt;code&gt;accept_max&lt;/code&gt; is &lt;code&gt;in_grade&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Residue above that and at or below &lt;code&gt;resort_max&lt;/code&gt; is &lt;code&gt;out_of_grade&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Residue above &lt;code&gt;resort_max&lt;/code&gt; is &lt;code&gt;heavy_residue&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Moisture is a boolean, and only for fiber. OCC and mixed paper compare &lt;code&gt;moisture_percent&lt;/code&gt; to &lt;code&gt;moisture_cap&lt;/code&gt;. PET can be wet in the ticket and the grader will say so in the trace via the moisture field, but it will not set &lt;code&gt;moisture_breach&lt;/code&gt;. I did that because my fiber story was about soggy bales, and I did not want a 2 percent moisture PET load to wobble.&lt;/p&gt;

&lt;p&gt;The caps I settled on, after the boring first run:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;OCC accepts through 5 percent residue, resorts through 12, moisture cap 18.&lt;/li&gt;
&lt;li&gt;Mixed paper accepts through 8, resorts through 18, moisture cap 16.&lt;/li&gt;
&lt;li&gt;PET and HDPE accept through 4, resort through 10, moisture cap 8, and that cap is unused for the breach flag.&lt;/li&gt;
&lt;li&gt;Glass accepts through 6, resorts through 15.&lt;/li&gt;
&lt;li&gt;LDPE film accepts through 10, resorts through 25. Film in my samples always carried loose paper, so a tight cap made every film ticket divert and I learned nothing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each commodity points at a card id. The card text is a short paragraph in &lt;code&gt;SPEC_CARDS&lt;/code&gt;. The coordinator copies the first 72 characters into the trace. That truncation is a little crude. It is enough to see &lt;code&gt;SPEC-OCC-01&lt;/code&gt; and the start of the sentence when I am scrolling a JSON dump.&lt;/p&gt;

&lt;h3&gt;
  
  
  Line router
&lt;/h3&gt;

&lt;p&gt;The router is the only step allowed to return a disposition. Order of checks matters, and I wrote it in that order so I would stop second-guessing it at midnight.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Prohibited terms return reject immediately. Moisture and residue do not get a vote.&lt;/li&gt;
&lt;li&gt;If the note contains &lt;code&gt;ceramic&lt;/code&gt; or &lt;code&gt;stoneware&lt;/code&gt;, return divert. LD-107 is glass at 4.4 percent residue, which is in grade, and it still diverts because the note mentions a ceramic mug.&lt;/li&gt;
&lt;li&gt;Otherwise map the band: in grade to accept, out of grade to resort, heavy residue to divert.&lt;/li&gt;
&lt;li&gt;If fiber moisture breached the cap, worsen one step. Accept becomes resort. Resort becomes divert. Divert stays divert.&lt;/li&gt;
&lt;li&gt;If the ticket is underweight and the current call is still accept, move it to resort.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I thought about making ceramic a reject. I left it as divert because, in the little model in my head, ceramics ruin a glass furnace story but they are not the same as a battery in a gaylord. That is my opinion inside an experiment. A different card could flip it later without touching the scout.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shift briefer
&lt;/h3&gt;

&lt;p&gt;The briefer builds one line: load id, disposition, line, commodity, residue, reasons. The coordinator then sorts those lines. I do not sort inside the briefer, because a single load has no neighbors yet. Batch order belongs to &lt;code&gt;run_shift&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi4omz9etsxffcgyb4knt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi4omz9etsxffcgyb4knt.png" alt="Workflow" width="704" height="1610"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The flow chart is the same story with the early exits drawn out. Prohibited notes never reach the grader's band mapping. Ceramic exits before the band split. Moisture sits after accept and resort, not after reject. I drew it that way after a bug where I applied moisture worsening to a reject and the test expected &lt;code&gt;quarantine_pad&lt;/code&gt; while the code said &lt;code&gt;residue_bunker&lt;/code&gt;. The chart is how I check that I have not reintroduced that bug when I stare at the function.&lt;/p&gt;

&lt;h3&gt;
  
  
  Clients
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;main.py&lt;/code&gt; loads JSON, runs the shift, prints the table and the brief, and optionally writes a chart and a JSON file. FastAPI has two posts: sample file, or a list of tickets in the body. Both call &lt;code&gt;run_shift&lt;/code&gt;. The HTML page is one file with a button. I did not add a build step. From my experience, a PoC dashboard that needs Node is where I stop maintaining the experiment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Get Cooking
&lt;/h2&gt;

&lt;p&gt;This is the part I reread when I forget which layer a function belongs to. The code is the PoC. The paragraphs under each block are what I was protecting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Semantic model
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;definition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;

&lt;span class="n"&gt;MEASURES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Measure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;Measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NetWeightLb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Load&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;definition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Net pounds on the scale ticket. Under 400 lb is an underweight flag.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ResiduePercent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Load&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;percent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;definition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Spot-check residue. The commodity metrics band this measure.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MoisturePercent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Load&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;percent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;definition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Moisture on the ticket. Compared with moisture_cap only for fiber commodities.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrote the measures as data, with a definition string, because a bare float named &lt;code&gt;residue_percent&lt;/code&gt; on the ticket was not a model. The ticket field is the physical column. &lt;code&gt;ResiduePercent&lt;/code&gt; is the semantic measure, and the definition says who is allowed to band it: the commodity metrics, not each agent independently. &lt;code&gt;project_load&lt;/code&gt; copies the three ticket numbers into a dict keyed by those measure names. The trace line starts with &lt;code&gt;semantic:&lt;/code&gt; and lists the keys plus the card id. When I scroll a JSON dump I want to see that the projection happened, even if the disposition later overrides the band.&lt;/p&gt;

&lt;p&gt;Commodity metrics are the other half of this layer. OCC's row says accept through 5, resort through 12, moisture cap 18, fiber true, card &lt;code&gt;SPEC-OCC-01&lt;/code&gt;. PET's row sets fiber false, so a wet PET ticket still carries &lt;code&gt;MoisturePercent&lt;/code&gt; as a measure and does not trip &lt;code&gt;moisture_breach&lt;/code&gt;. The measure exists for every load. The comparison rule is a property of the commodity relationship. That distinction is the whole reason I split the model from the router.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ticket model
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Commodity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;OCC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;occ&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;MIXED_PAPER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mixed_paper&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;PET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;HDPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hdpe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;GLASS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;glass&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;LDPE_FILM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ldpe_film&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InboundLoad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;load_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;supplier_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;commodity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Commodity&lt;/span&gt;
    &lt;span class="n"&gt;net_weight_lb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;80_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;residue_percent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;100.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;moisture_percent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;100.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;spot_check_notes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="n"&gt;received_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I started here because every later bug I have had in agent experiments was a stringly-typed field. &lt;code&gt;commodity&lt;/code&gt; as an enum means &lt;code&gt;sample_loads.json&lt;/code&gt; cannot say &lt;code&gt;"cardboard"&lt;/code&gt; and silently skip a spec. I found that out by typoing &lt;code&gt;mixed-paper&lt;/code&gt; with a hyphen and watching Pydantic reject the file. That failure is the one I want at startup. &lt;code&gt;spot_check_notes&lt;/code&gt; defaults to empty so a ticket with no note still grades on numbers. Empty notes produce no prohibited hits, which is correct for this PoC and dangerous if you pretend the absence of a note means somebody looked carefully. I do not pretend that. The brief still shows the residue band.&lt;/p&gt;

&lt;h3&gt;
  
  
  Spec table
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;Commodity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OCC&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accept_max&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;5.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resort_max&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;12.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;moisture_cap&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;18.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OCC fiber&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;card_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SPEC-OCC-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OCC is the card I edited most. The rest of the dict follows the same keys, which matters more than the individual percents. The grader casts &lt;code&gt;accept_max&lt;/code&gt; with &lt;code&gt;float()&lt;/code&gt; because I stored a mix of numbers and label strings in one dict and I did not want a surprise. A typed model for the spec would be cleaner. I left the dict because I can see every commodity on one screen. The card id is the join key into &lt;code&gt;SPEC_CARDS&lt;/code&gt;, where the paragraph version of the rule lives. I duplicate the numbers in prose on purpose. When the prose and the float disagree, I treat that as a bug in my experiment, not as documentation drift I can ignore. The tests assert behavior from the floats. The paragraphs are for me when I open the file in six weeks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Residue scout
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;residue_scout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;InboundLoad&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spot_check_notes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;term&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;term&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PROHIBITED_TERMS&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;term&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scout: residue=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;residue_percent&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;% &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;moisture=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;moisture_percent&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;% prohibited=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scout returns hits and does not return a disposition. I wrote it that way after an early version returned &lt;code&gt;"reject"&lt;/code&gt; as a string and the router had to understand two dialects. A list of terms is dumber and easier. The trace line includes residue and moisture even though the grader also prints them. When I tail a trace I want the scout line to stand alone. &lt;code&gt;hits or ['none']&lt;/code&gt; keeps the log from saying &lt;code&gt;prohibited=[]&lt;/code&gt;, which I misread once as a real hit on an empty string. Small thing. It saved me a confused minute, so it stayed.&lt;/p&gt;

&lt;p&gt;Substring matching is the limitation I already mentioned. It will also false-positive if I ever write a note like "no battery found" because &lt;code&gt;battery&lt;/code&gt; is still in the sentence. None of the sample notes do that. If I extend the PoC I would switch this to a word-boundary check and add a negation test. I am not going to claim the current list is careful. It is a tripwire for the sample set.&lt;/p&gt;

&lt;h3&gt;
  
  
  Router and the one-step worsen
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_worsen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;disposition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Disposition&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Disposition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;disposition&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;Disposition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ACCEPT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Disposition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RESORT&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;disposition&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;Disposition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RESORT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Disposition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DIVERT&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;disposition&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This helper is the whole moisture policy. I like that it is short enough to argue with. There is no path from divert to reject here. A soaked, filthy OCC load (LD-103, 16.8 percent residue, 22.5 percent moisture) is already divert because of heavy residue. Moisture adds the reason &lt;code&gt;moisture_cap&lt;/code&gt; and the disposition stays divert. The brief shows both reasons: &lt;code&gt;heavy_residue, moisture_cap&lt;/code&gt;. I want both. If I only kept the final disposition I would forget that the bale was wet and dirty, not just dirty.&lt;/p&gt;

&lt;p&gt;The router calls &lt;code&gt;_worsen&lt;/code&gt; only after the band map, and only when &lt;code&gt;moisture_breach&lt;/code&gt; is true. Prohibited loads return earlier, so they never worsen. That is the bugfix I drew into the flow chart.&lt;/p&gt;

&lt;p&gt;Underweight handling sits beside it, not inside &lt;code&gt;_worsen&lt;/code&gt;, because underweight is not a residue story. A 280 pound in-grade OCC ticket becomes resort with reasons &lt;code&gt;in_grade&lt;/code&gt; and &lt;code&gt;underweight_ticket&lt;/code&gt;. The grade band on the decision stays &lt;code&gt;in_grade&lt;/code&gt;. I kept the original band so a JSON diff can show that the grader and the router disagreed in a controlled way. If I overwrote the band, I would lose that disagreement and the next debugging session would be worse.&lt;/p&gt;

&lt;h3&gt;
  
  
  One load, five steps
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decide_load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;InboundLoad&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;LoadDecision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;flags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;intake_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;prohibited&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;residue_scout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;band&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;card_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;moisture_breach&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;commodity_grader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;disposition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reasons&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;line_router&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prohibited&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;band&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;moisture_breach&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;line_assignment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LINE_FOR&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;disposition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;brief_line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;shift_briefer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;disposition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line_assignment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;card_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SPEC_CARDS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;card_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;card: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;card_text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;LoadDecision&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I trimmed the constructor in the snippet above so the sequence is the thing you see. The real function fills every field on &lt;code&gt;LoadDecision&lt;/code&gt;, including the residue numbers copied off the ticket. I copy them instead of making the caller join back to the raw load. When I write &lt;code&gt;--json-out&lt;/code&gt;, one object should explain itself.&lt;/p&gt;

&lt;p&gt;The trace is a list of strings, not a tree. I thought about nested spans. For ten tickets, strings were enough, and pytest can &lt;code&gt;startswith("intake:")&lt;/code&gt;. A fancy trace format would have been me playing with tooling. The order is fixed: intake, scout, grader, router, briefer, card excerpt. If a future step lands in the middle, the tests that assume a prefix on element zero still pass, and the tests that assume the last line is the card will need an edit. I will take that edit. It is honest.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;run_shift&lt;/code&gt; maps &lt;code&gt;decide_load&lt;/code&gt; across the list, counts dispositions, averages residue, and builds the brief. Average residue on the sample is 8.36 percent. I round to two decimals in the report so the CLI, the chart title, and this article do not drift by a float. The chart title includes that average because a bar chart of counts hides whether the batch was mostly clean with one disaster or uniformly messy. This batch is mixed, which is what I wanted from the sample authoring pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Setup
&lt;/h2&gt;

&lt;p&gt;The public repository is &lt;a href="https://github.com/aniket-work/ResidueDesk-AI" rel="noopener noreferrer"&gt;https://github.com/aniket-work/ResidueDesk-AI&lt;/a&gt;. I publish only the implementation, &lt;code&gt;images/&lt;/code&gt;, and &lt;code&gt;README.md&lt;/code&gt;. The article generator stays in my notes, not in that repo.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Clone &lt;code&gt;&lt;a href="https://github.com/aniket-work/ResidueDesk-AI.git" rel="noopener noreferrer"&gt;https://github.com/aniket-work/ResidueDesk-AI.git&lt;/a&gt;&lt;/code&gt; and change into the directory.&lt;/li&gt;
&lt;li&gt;Create a virtual environment in the project folder and activate it. I keep the venv inside the project so this experiment does not leak packages into whatever else I am running.&lt;/li&gt;
&lt;li&gt;Install from &lt;code&gt;requirements.txt&lt;/code&gt;. You need pydantic, rich, matplotlib, fastapi, uvicorn, and pytest.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;python -m pytest tests -q&lt;/code&gt;. Six tests should pass. If the battery test fails, the scout or the early return in the router moved.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;python main.py --chart images/disposition-chart.png&lt;/code&gt;. You should see ten rows and a brief that starts with LD-106.&lt;/li&gt;
&lt;li&gt;Optional: &lt;code&gt;uvicorn src.residuedesk.api:app --reload&lt;/code&gt; and open the local dashboard. The button posts to the sample endpoint. Same counts.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I use Python 3.11. Newer 3.12 runs I tried on this code also imported fine, but I am not promising a matrix. If &lt;code&gt;matplotlib&lt;/code&gt; complains about a cache directory on a locked-down laptop, the CLI table still works without &lt;code&gt;--chart&lt;/code&gt;. The chart is for the picture, not for the decision.&lt;/p&gt;

&lt;p&gt;Environment variables: none. If a README in some other project of mine asks for an API key, that pattern does not apply here. I got tired of demos that die in setup because a key is missing for a step that could have been a lookup table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Run
&lt;/h2&gt;

&lt;p&gt;Here is the sample, load by load, the way the code calls them. I am listing them in ticket order first, then I will say what the brief does with that order.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;LD-101&lt;/strong&gt; is OCC, 18,420 lb, residue 3.2, moisture 8, note about dry gaylords. Accept, tip floor. This is my control for "nothing is wrong."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LD-102&lt;/strong&gt; is OCC, residue 9.4, moisture 11, pizza boxes in the bales. Out of grade, resort, QC bay. Moisture is under the 18 percent cap, so no worsen step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LD-103&lt;/strong&gt; is OCC, residue 16.8, moisture 22.5, rain-soaked. Heavy residue means divert. Moisture adds a reason and does not create a new disposition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LD-104&lt;/strong&gt; is PET, residue 2.1, caps mostly off. Accept. Second control, different commodity, so a bug that only special-cases OCC still has to face this row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LD-105&lt;/strong&gt; is PET, residue 7.6, label sleeves. Resort. The sleeve words do not match the prohibited list, and they should not. Sleeves are a grade problem, which the residue percent already represents in this experiment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LD-106&lt;/strong&gt; is HDPE, residue 3.1, battery pack in the note. Reject, quarantine pad. The grader still runs and still records an in-grade band, because &lt;code&gt;decide_load&lt;/code&gt; calls the grader before the router returns. I could skip the grader on a prohibited hit. I left the call so the trace shows the temptation: the numbers were fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LD-107&lt;/strong&gt; is glass, residue 4.4, ceramic mug fragments. Divert, reason &lt;code&gt;non_spec_ceramic&lt;/code&gt;. Band would have been in grade. The router ignores that band in this branch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LD-108&lt;/strong&gt; is mixed paper, residue 6.2, moisture 19.4. In grade against an 8 percent accept cap, then moisture cap 16 forces resort.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LD-109&lt;/strong&gt; is LDPE film, residue 28, loose paper and strapping. Heavy residue, divert. No prohibited terms. This is the "dirty but not dangerous" case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LD-110&lt;/strong&gt; is OCC, residue 2.8, only 280 lb. In grade, then underweight pushes resort.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The brief reorders that list. Reject first (LD-106), then the three diverts (LD-103, LD-107, LD-109) by load id, then resorts, then the two accepts. I sort diverts by id so the order does not depend on dict hash or file order beyond the severity key. File order still matters for the table, which follows the JSON. I want the table to match the ticket file and the brief to match the handoff. Those are different readers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp0r67lw3c4qx00s9uejh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp0r67lw3c4qx00s9uejh.png" alt="Disposition chart" width="800" height="313"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The chart is those four counts. Accept 2, resort 4, divert 3, reject 1. The title on the image includes the 8.36 percent average. If you change one threshold and regenerate, the bars move and the average may not. LD-103 at 16.8 percent pulls the average up. A batch of LD-101 clones would sit near 3 percent and the bars would be a single accept column. I kept the sample uneven so the chart would look like a morning, not like a unit test fixture that forgot to be a fixture.&lt;/p&gt;

&lt;p&gt;JSON output is the habit I actually use. &lt;code&gt;python main.py --json-out shift.json&lt;/code&gt; writes the report, including every trace. When I moved the OCC accept cap from 8 to 5, I diffed the JSON. LD-102 changed from accept to resort. Nothing else moved. That diff is how I trust a one-line constant edit. Terminal tables lie by omission when you have scrolled.&lt;/p&gt;

&lt;p&gt;The dashboard button hits &lt;code&gt;POST /api/triage/sample&lt;/code&gt;. The cards on the page are the same four counts. I styled it dark so it would resemble the terminal GIF, not because dark theme is a personality. If the API and the CLI ever disagree, I broke the "one &lt;code&gt;run_shift&lt;/code&gt;" rule and I should revert that, not add a third client to average them out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cases I had to pin with tests
&lt;/h2&gt;

&lt;p&gt;I do not add a test for every row. I add a test when a rule exists only because I got burned.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Clean OCC accepts and the line is &lt;code&gt;tip_floor_primary&lt;/code&gt;. If I rename the line dict and forget accept, this fails.&lt;/li&gt;
&lt;li&gt;A battery note on HDPE rejects, and some reason starts with &lt;code&gt;prohibited:&lt;/code&gt;. I check the prefix so I can add terms without rewriting the assertion.&lt;/li&gt;
&lt;li&gt;Wet mixed paper stays &lt;code&gt;in_grade&lt;/code&gt; on the band and still resorts, with &lt;code&gt;moisture_cap&lt;/code&gt; in the reasons. This is the test that protects the "keep the original band" decision.&lt;/li&gt;
&lt;li&gt;Ceramic glass diverts with &lt;code&gt;non_spec_ceramic&lt;/code&gt;. Residue in that test is 4.4, inside the accept band, so a future "optimization" that returns early on in-grade would fail here. Good.&lt;/li&gt;
&lt;li&gt;Underweight clean OCC resorts. Weight is 280, residue 2.8.&lt;/li&gt;
&lt;li&gt;The sample file counts stay &lt;code&gt;accept 2, resort 4, divert 3, reject 1&lt;/code&gt;, and the brief contains &lt;code&gt;LD-106&lt;/code&gt;. This is the regression net for the whole batch. When I add an eleventh ticket I have to edit this test on purpose. I want that friction.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The sixth test also checks that the first trace line starts with &lt;code&gt;intake:&lt;/code&gt;. That caught a refactor where I logged the scout first "because it is more interesting." It was more interesting and it was the wrong order. Intake flags are cheap and they should exist even when a later step throws, which is a property I want if I ever let the router raise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the experiment is thin
&lt;/h2&gt;

&lt;p&gt;Keyword notes are thin. A photo of a gaylord is not in this repo. I am not going to bolt on an image model and call the PoC done. The note is a stand-in for a person who already looked. If nobody looked, the scout is theater. I say that in the README limits section and I am repeating it here because it is the limitation that matters.&lt;/p&gt;

&lt;p&gt;The caps are thin in a different way. I moved them until the sample set exercised each branch. That is a good way to test software and a bad way to claim you measured a commodity market. There is no price, no buyer, no revenue figure in the project. I left money out on purpose. Disposition is the decision I wanted to practice. A price would have turned the brief into a negotiation, and that is a different experiment.&lt;/p&gt;

&lt;p&gt;Supplier ids look like codes (&lt;code&gt;NW-FIBER-04&lt;/code&gt;, &lt;code&gt;HARBOR-PET-02&lt;/code&gt;) and they do nothing except print. I thought about a supplier strike count, where two bad loads from &lt;code&gt;NW-FIBER-11&lt;/code&gt; escalate the third. LD-102 and LD-103 share that supplier. I did not implement the strike. A hidden counter would make the batch order change results, and then the JSON diff habit gets muddy. If I add it, it will be an explicit field on the report, not a global.&lt;/p&gt;

&lt;p&gt;Timestamps are stored and barely used. A shift desk in a fuller experiment would group by hour and complain when tickets arrive out of order. I did not need that to prove the coordinator. Unused fields bother me less than fields that silently change a disposition.&lt;/p&gt;

&lt;p&gt;FastAPI is thicker than the PoC needs. I kept it because I wanted a second client. The HTML is not a product surface. It does not authenticate. Do not expose it on a network and pretend the quarantine pad is protected. My laptop, my sample file, my button.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would add if I keep going
&lt;/h2&gt;

&lt;p&gt;These are notes to myself, not a roadmap with dates.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Word-boundary matching on the prohibited list, plus a test where the note says the battery was already removed.&lt;/li&gt;
&lt;li&gt;A sixth step that rewrites &lt;code&gt;brief_line&lt;/code&gt; into a shorter spoken sentence, while &lt;code&gt;disposition&lt;/code&gt; stays an enum the chart can count.&lt;/li&gt;
&lt;li&gt;Supplier history as an input column, not as hidden state, so two bad loads only matter if the ticket says so.&lt;/li&gt;
&lt;li&gt;A held-out JSON file of tickets I did not use to tune caps, so I can see which constants I overfit.&lt;/li&gt;
&lt;li&gt;Embedding search over a longer card file, measured against the lexical id I already store. If embeddings do not change a single disposition on the held-out file, I will delete them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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 reasons list. The reasons list is the part that caught the moisture double-count bug. I am keeping it in the foreground.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I checked the pictures
&lt;/h2&gt;

&lt;p&gt;The cover GIF is a terminal, then the four count cards and the chart. The terminal types &lt;code&gt;python main.py --chart images/disposition-chart.png&lt;/code&gt;, scrolls the summary, and holds on the success line with the 8.36 percent average. I care that the GIF matches the CLI, because I have published pictures that were a fictional dashboard sitting on top of different code. The counts in the GIF are 2, 4, 3, and 1. If you run the sample and see something else, trust the terminal and assume I forgot to regenerate the GIF.&lt;/p&gt;

&lt;p&gt;The mermaid figures are the title view, the module map, the sequence, and the flow. They live under &lt;code&gt;images/&lt;/code&gt; in the repo. I regenerated the flow once after a label with a question mark failed to render. The version in the repo is the one without that character in the diamond nodes. The behavior in code still asks the questions. The drawing just uses plainer labels.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flnztyyfawv2a9whwmpm1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flnztyyfawv2a9whwmpm1.png" alt="Title diagram" width="800" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;ResidueDesk-AI is a semantic model of a shift, plus a brief I can run with no key and no network. Load, Commodity, and Supplier are the entities. Residue, moisture, and net weight are the measures. The agents read a projection and then apply the overrides I was willing to write down: battery notes quarantine, wet fiber worsens one step, ceramic cullet diverts. The battery load stays quarantined. The wet mixed-paper load does not get a free pass because residue was 6.2. The ceramic cullet does not ride along just because 4.4 percent looks tidy. Those three outcomes are the experiment.&lt;/p&gt;

&lt;p&gt;I will keep using this shape on later personal projects: a semantic layer that names the grain and the measures, a narrow scout for the sentence that should veto the numbers, a grader that only bands a measure, a router that owns the enum, and a brief sorted by how badly I need to see the row. The domain can change. The laptop can stay the place it runs. The repository is &lt;a href="https://github.com/aniket-work/ResidueDesk-AI" rel="noopener noreferrer"&gt;https://github.com/aniket-work/ResidueDesk-AI&lt;/a&gt; if you want the same sample morning I used while writing this.&lt;/p&gt;

&lt;p&gt;The chart and the table will go stale the moment you edit &lt;code&gt;COMMODITY_METRICS&lt;/code&gt;. That is the correct failure. Update the test counts when you mean to, and leave them failing when you do not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclaimer
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Tags: python, ai, opensource, recycling&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>opensource</category>
      <category>recycling</category>
    </item>
    <item>
      <title>Building an Intelligent Fleet Maintenance Compliance Coordinator with LangGraph</title>
      <dc:creator>Aniket Hingane</dc:creator>
      <pubDate>Tue, 30 Jun 2026 02:46:51 +0000</pubDate>
      <link>https://dev.to/exploredataaiml/building-an-intelligent-fleet-maintenance-compliance-coordinator-with-langgraph-45mi</link>
      <guid>https://dev.to/exploredataaiml/building-an-intelligent-fleet-maintenance-compliance-coordinator-with-langgraph-45mi</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F14nlq9092ymrbtx7j05g.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F14nlq9092ymrbtx7j05g.gif" alt="Cover" width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How I Automated DOT Inspection Triage Using Multi-Agent Business Rules&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Part 1 — Context and Foundations
&lt;/h2&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I built FleetGuard-AI as a personal proof-of-concept to explore a problem I kept noticing while experimenting with fleet operations patterns: maintenance and compliance data is easy to collect from telematics, ELD exports, and shop management systems, but the hard part is deciding what each vehicle record means when DOT inspection windows, brake wear curves, tire tread rules, PM mileage intervals, and open defect reports all interact at dispatch time. This experimental project routes fleet maintenance records through a LangGraph-orchestrated multi-agent pipeline backed by FastAPI and a lightweight FMCSA-style policy knowledge index. Each record passes through DOT inspection validation, brake and tire checks, preventive maintenance gates, ELD hour limits, defect documentation review, compliance retrieval, and action generation before landing in a structured report with urgency levels and agent traces. The full source code lives at &lt;a href="https://github.com/aniket-work/FleetGuard-AI" rel="noopener noreferrer"&gt;https://github.com/aniket-work/FleetGuard-AI&lt;/a&gt;. From my experience, the most interesting takeaway is not any single agent prompt, but how graph-based orchestration keeps specialist compliance steps composable without turning business logic into spaghetti. This write-up documents my design choices, the code I wrote along the way, and what I would change if I extended the PoC further.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;A few weeks ago I started paying closer attention to what happens after a morning dispatch review surfaces a Class 8 tractor with twelve percent brake pad life remaining, a steer-axle tire at three and a half thirty-seconds tread depth, and a DOT inspection that is three hundred forty days old. From my observation reading fleet compliance forums and anonymized maintenance exports, the numbers themselves are rarely the whole story. A dispatcher still has to answer harder questions. Is this vehicle legally out of service or merely approaching a warning threshold? Does the steer-axle note change tire minimums? Which FMCSA policy clause applies, and how quickly should someone pull the unit before a roadside inspection turns into a citation?&lt;/p&gt;

&lt;p&gt;I wanted to test whether multi-agent orchestration could compress that triage work without pretending to replace human judgment. FleetGuard-AI is the result of that experiment. It is not production software, not something I deployed at any employer, and not a claim about how commercial fleets should operate. It is a solo PoC I built to learn LangGraph patterns in a domain that felt concrete and underserved in agent tutorials.&lt;/p&gt;

&lt;p&gt;The architecture rhymes with other validation experiments I have tried: specialist agents, a coordinator graph, a knowledge layer for policy retrieval, and a thin API for integration. I deliberately chose fleet maintenance compliance rather than chatbots or document summarization because those demos are overrepresented, and because roadside-ready violations have crisp numeric inputs and operational outputs that make evaluation honest.&lt;/p&gt;

&lt;p&gt;What pulled me toward this domain specifically was the asymmetry between measurement and response. Modern fleets already emit odometer readings, brake wear percentages, tire tread measurements, ELD driving hours, and open DVIR defect counts from shop systems and telematics gateways. The gap I noticed was structured validation: turning raw maintenance records into urgency labels, compliance violation tags, policy citations, and recommended actions that a dispatcher could accept or override quickly. I wondered whether explicit agent roles could mirror that mental sorting without hiding reasoning inside a black-box model.&lt;/p&gt;

&lt;p&gt;I also wanted a use case where mistakes are obvious. If an agent labels a tractor with eight percent brake pads as cleared for dispatch, tests should scream. That clarity helped me iterate faster than I would have in a vague business insights demo where quality is subjective.&lt;/p&gt;

&lt;p&gt;From where I stand, fleet compliance validation shares a structural pattern with other enterprise-style agent experiments I have built: you receive structured events, apply domain-specific business rules in layers, attach policy citations, and emit operational recommendations with explicit urgency. I did not copy any prior tutorial wholesale. Instead, I asked what "validation" means when the asset is a regulated commercial vehicle rather than a shopping cart, then designed agents around that question.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's This Article About?
&lt;/h2&gt;

&lt;p&gt;This article walks through how I designed and implemented FleetGuard-AI, a multi-agent system that ingests batches of fleet maintenance records and emits compliance reports containing violation labels, urgency scores, FMCSA-style policy references, recommended interventions, and response hour estimates. You will see the LangGraph state machine I used, the specialist agents I wrote, the policy knowledge module that acts as a stand-in for retrieval-augmented generation, and the FastAPI surface that exposes the workflow to a simple dashboard.&lt;/p&gt;

&lt;p&gt;I also cover setup and execution steps so you can reproduce the PoC locally, and I share edge cases I discovered while testing, including steer-axle tire thresholds that only tighten when dispatcher notes mention steer axle wear. Throughout, I frame conclusions as personal observations from an experimental build rather than authoritative guidance for regulated fleet environments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;p&gt;I kept the stack intentionally small. Complexity in agent systems often hides in dependencies long before it hides in prompts, and I wanted to feel every layer.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Python 3.11&lt;/strong&gt; as the runtime. Strong typing with Pydantic models made maintenance record batches easy to validate at API boundaries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LangGraph&lt;/strong&gt; for orchestration. I considered a hand-rolled coordinator loop, but LangGraph gave me explicit state transitions and a graph I could diagram without lying.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FastAPI&lt;/strong&gt; for HTTP endpoints and to serve a minimal HTML dashboard from the same process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pydantic v2&lt;/strong&gt; for schemas such as &lt;code&gt;MaintenanceRecord&lt;/code&gt;, &lt;code&gt;ComplianceResult&lt;/code&gt;, and &lt;code&gt;BatchComplianceReport&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rich&lt;/strong&gt; for CLI tables. Terminal output matters in fleet operations workflows because many dispatch tools still live partly in terminals and JSON exports.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Matplotlib&lt;/strong&gt; for urgency and violation distribution charts. I wanted at least one visual summary of learned compliance statistics, not because the charts are fancy, but because they make batch behavior obvious at a glance.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I skipped a full vector database in this PoC. A lexical policy index was enough to prove the RAG insertion point, and it kept the repository approachable for readers who want to clone and run in ten minutes.&lt;/p&gt;

&lt;p&gt;As per my experience shipping small services, I optimize for clone-to-green time in PoCs. Every extra infrastructure component is a reader who bounces at step four of the README. LangGraph plus FastAPI plus a JSON fixture gets people to a colored terminal table quickly. That table is the emotional payoff that convinces someone to read the rest of the design sections.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Role in FleetGuard-AI&lt;/th&gt;
&lt;th&gt;Why I chose it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LangGraph&lt;/td&gt;
&lt;td&gt;Per-record compliance loop&lt;/td&gt;
&lt;td&gt;Makes orchestration visible and testable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FastAPI&lt;/td&gt;
&lt;td&gt;HTTP plus static dashboard&lt;/td&gt;
&lt;td&gt;Single process dev experience&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pydantic&lt;/td&gt;
&lt;td&gt;Schema guardrails&lt;/td&gt;
&lt;td&gt;Prevents malformed record batches early&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rich&lt;/td&gt;
&lt;td&gt;CLI tables&lt;/td&gt;
&lt;td&gt;Matches dispatcher-friendly output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Matplotlib&lt;/td&gt;
&lt;td&gt;Urgency and violation charts&lt;/td&gt;
&lt;td&gt;Quant summary for README and article&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvw41e61uj66v4ou46bq8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvw41e61uj66v4ou46bq8.png" alt="Architecture" width="800" height="298"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2 — Design Rationale
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Why Read It?
&lt;/h2&gt;

&lt;p&gt;If you are experimenting with LangGraph and tired of toy chatbots, fleet maintenance compliance offers a disciplined sandbox. Inputs are structured maintenance records with vehicle class metadata. Outputs are enums, policy codes, and action lists. That makes it easy to tell when an agent misbehaved without hand-waving about subjective quality.&lt;/p&gt;

&lt;p&gt;From my perspective, three ideas in this PoC transfer to other domains:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Specialist agents beat monolithic prompts&lt;/strong&gt; when each step has different failure modes. DOT inspection currency and brake wear scoring should not share one prompt context window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graph orchestration documents operational process&lt;/strong&gt; better than nested if-statements. Dispatchers already think in workflows; LangGraph mirrors that honestly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A thin API unlocks multiple clients&lt;/strong&gt; with one workflow. I used both a Rich CLI and a browser dashboard against the same compliance function.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The GitHub repository at &lt;a href="https://github.com/aniket-work/FleetGuard-AI" rel="noopener noreferrer"&gt;https://github.com/aniket-work/FleetGuard-AI&lt;/a&gt; includes tests, sample data, diagrams, and a title animation GIF generated from terminal output plus a dashboard preview. Clone it, run &lt;code&gt;python main.py&lt;/code&gt;, and you will see the same ASCII table aesthetic I embedded in the article cover animation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Design
&lt;/h2&gt;

&lt;p&gt;Before writing code I sketched the lifecycle of a single maintenance record. A fleet system reports odometer miles, days since last DOT inspection, miles since last PM service, brake pad percent remaining, tire tread depth in thirty-seconds of an inch, seven-day ELD driving hours, open defect counts, maintenance log completeness, and dispatcher notes. A compliance system must answer six questions: is the DOT annual inspection current, are brakes and tires within vehicle-class thresholds, is preventive maintenance overdue, are ELD hours within federal limits, are open defects and documentation gaps blocking dispatch, and what intervention urgency is appropriate. Those questions map cleanly to five agent responsibilities plus a coordinator.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyjj1a1regv3abujtaqyu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyjj1a1regv3abujtaqyu.png" alt="Sequence" width="800" height="325"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Coordinator and state
&lt;/h3&gt;

&lt;p&gt;The coordinator owns batch progress. LangGraph state carries the record list, accumulated results, fleet name, and a numeric index. I chose explicit index-based iteration rather than recursive graph tricks because it made debugging easier when a single record produced surprising output.&lt;/p&gt;

&lt;h3&gt;
  
  
  DOT inspection agent
&lt;/h3&gt;

&lt;p&gt;The DOT inspection agent checks whether the annual inspection is overdue beyond three hundred sixty-five days or approaching the three hundred thirty-day warning window. Vehicles past the hard limit flag &lt;code&gt;dot_inspection_overdue&lt;/code&gt;. Vehicles in the warning band flag &lt;code&gt;dot_inspection_due_soon&lt;/code&gt;. I put inspection currency first because, in my opinion, operating an out-of-compliance vehicle is worse than delaying a load by a few hours.&lt;/p&gt;

&lt;h3&gt;
  
  
  Brake and tire agent
&lt;/h3&gt;

&lt;p&gt;This agent encodes the mechanical intuition that actually grounds roadside inspections. Class 7 and Class 8 tractors carry different brake pad critical and warning thresholds. Tire tread minimums tighten when dispatcher notes mention steer axle wear, because steer tires face stricter federal minimums than drive tires. Encoding these as lookup tables made pytest assertions straightforward.&lt;/p&gt;

&lt;h3&gt;
  
  
  PM service agent
&lt;/h3&gt;

&lt;p&gt;Preventive maintenance intervals differ by vehicle class. Refrigerated units use a fifteen thousand mile PM window instead of the twenty five thousand mile default for linehaul tractors. When miles since last PM exceed the limit, the agent flags &lt;code&gt;pm_service_overdue&lt;/code&gt;. When the vehicle is within three thousand miles of the limit, it flags &lt;code&gt;pm_service_due_soon&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  ELD hours agent
&lt;/h3&gt;

&lt;p&gt;This agent encodes a regulatory gate I rarely see in generic fleet demos: cumulative driving hours over a seven-day window cannot exceed sixty hours under standard rules. Above sixty triggers &lt;code&gt;eld_hours_exceeded&lt;/code&gt;. Between fifty-five and sixty the agent logs an approaching-limit trace without flagging a violation yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Defect and documentation agent
&lt;/h3&gt;

&lt;p&gt;Open DVIR defects, missing maintenance logs, and odometer discrepancies flagged in dispatcher notes each produce distinct violation types. A note mentioning "odometer discrepancy" or "mileage gap" triggers &lt;code&gt;odometer_gap&lt;/code&gt; even when numeric fields look otherwise normal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compliance policy and action agents
&lt;/h3&gt;

&lt;p&gt;The compliance policy agent queries a small FMCSA-style policy chunk index. Each chunk includes a code like &lt;code&gt;FMCSA-DOT-001&lt;/code&gt;, title, body, and vehicle class tag. Search is lexical overlap with class and category boosts. The action agent converts urgency into recommended interventions and response hour counts. Critical brake wear triggers immediate pull-from-dispatch steps with a two-hour response window.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi26xjkzfhuttnavithb0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi26xjkzfhuttnavithb0.png" alt="Workflow" width="530" height="1595"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  API and clients
&lt;/h3&gt;

&lt;p&gt;FastAPI exposes &lt;code&gt;/api/validate/sample&lt;/code&gt; for the bundled dataset and &lt;code&gt;/api/validate&lt;/code&gt; for custom payloads. The root route serves a dark-themed dashboard that renders urgency cards and a results table after a POST call. I put the frontend in a single HTML file to avoid Node build tooling in a PoC README.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3 — Implementation Deep Dive
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Let's Get Cooking
&lt;/h2&gt;

&lt;p&gt;Here is where I translate the design into code. I split the implementation into schema models, specialist agents, the LangGraph workflow, and the API layer. I wrote each block to be readable in isolation because, in my experience, agent repos rot quickly when everything lives in one thousand-line module.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data models
&lt;/h3&gt;

&lt;p&gt;I started with Pydantic models because maintenance records arrive from JSON exports and telematics gateways in real life, and I wanted validation before any agent touched the data. Strong typing also made FastAPI response models trivial to declare.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UrgencyLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;EMERGENCY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;emergency&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;HIGH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;MEDIUM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;LOW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;NORMAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;normal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ComplianceViolation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;DOT_INSPECTION_OVERDUE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dot_inspection_overdue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;DOT_INSPECTION_DUE_SOON&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dot_inspection_due_soon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;BRAKE_WEAR_CRITICAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;brake_wear_critical&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;BRAKE_WEAR_WARNING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;brake_wear_warning&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;TIRE_TREAD_CRITICAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tire_tread_critical&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;TIRE_TREAD_WARNING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tire_tread_warning&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;ODOMETER_GAP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;odometer_gap&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;PM_SERVICE_OVERDUE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pm_service_overdue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;PM_SERVICE_DUE_SOON&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pm_service_due_soon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;ELD_HOURS_EXCEEDED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eld_hours_exceeded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;DEFECT_REPORT_OPEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defect_report_open&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;DOCUMENTATION_MISSING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;documentation_missing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MaintenanceRecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;record_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;vehicle_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;vehicle_class&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;VehicleClass&lt;/span&gt;
    &lt;span class="n"&gt;odometer_miles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2_000_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;last_dot_inspection_days_ago&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;730&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;last_pm_service_miles_ago&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;500_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;brake_pad_percent_remaining&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;100.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;tire_tread_depth_32nds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;32.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;eld_driving_hours_7day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;80.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;open_defect_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;has_maintenance_log&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="n"&gt;recorded_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;dispatcher_notes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These models became the contract between CLI, API, and agents. Keeping enums strict prevented silent string drift in urgency labels when I generated charts later. I designed &lt;code&gt;dispatcher_notes&lt;/code&gt; as a first-class field because steer-axle tire logic and odometer gap detection depend on human context that pure sensor readings cannot capture.&lt;/p&gt;

&lt;h3&gt;
  
  
  DOT inspection agent
&lt;/h3&gt;

&lt;p&gt;The DOT inspection agent is the first specialist in the pipeline. It answers the simplest regulatory question first: has this vehicle exceeded the annual inspection window?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;DOT_INSPECTION_MAX_DAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;365&lt;/span&gt;
&lt;span class="n"&gt;DOT_INSPECTION_WARN_DAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;330&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;dot_inspection_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MaintenanceRecord&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ComplianceViolation&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
  &lt;span class="n"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ComplianceViolation&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_dot_inspection_days_ago&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DOT_INSPECTION_MAX_DAYS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="n"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ComplianceViolation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOT_INSPECTION_OVERDUE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_dot_inspection_days_ago&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DOT_INSPECTION_WARN_DAYS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="n"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ComplianceViolation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOT_INSPECTION_DUE_SOON&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DotInspectionAgent: days_since_inspection=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_dot_inspection_days_ago&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
      &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;violations=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;violations&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I put this agent first because inspection currency is a binary compliance gate. A vehicle three hundred seventy-two days past inspection, like refrigerated unit REF-3301 in my sample batch, should never slip through because brake wear looked acceptable. The trace line records both the raw day count and violation count so I can audit decisions in the CLI without re-running the graph.&lt;/p&gt;

&lt;h3&gt;
  
  
  Brake and tire agent
&lt;/h3&gt;

&lt;p&gt;Mechanical wear is where fleet business logic actually lives. Class 8 tractors tolerate higher brake pad wear before critical thresholds than light-duty vans. Steer-axle notes tighten tire minimums because federal steer tire rules are stricter than drive tire rules.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;BRAKE_CRITICAL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;VehicleClass&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;VehicleClass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CLASS_7&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;15.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;VehicleClass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CLASS_8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;20.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;VehicleClass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LIGHT_DUTY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;10.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;VehicleClass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REFRIGERATED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;18.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;brake_tire_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MaintenanceRecord&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ComplianceViolation&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ComplianceViolation&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;brake_crit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BRAKE_CRITICAL&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vehicle_class&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;brake_warn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BRAKE_WARNING&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vehicle_class&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;brake_pad_percent_remaining&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;brake_crit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ComplianceViolation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BRAKE_WEAR_CRITICAL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;brake_pad_percent_remaining&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;brake_warn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ComplianceViolation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BRAKE_WEAR_WARNING&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;steer_note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;steer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dispatcher_notes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;tire_crit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;STEER_TIRE_CRITICAL_32NDS&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;steer_note&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;TIRE_CRITICAL_32NDS&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tire_tread_depth_32nds&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;tire_crit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ComplianceViolation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TIRE_TREAD_CRITICAL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tire_tread_depth_32nds&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;TIRE_WARNING_32NDS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ComplianceViolation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TIRE_TREAD_WARNING&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BrakeTireAgent: brake=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;brake_pad_percent_remaining&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;%, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tread=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tire_tread_depth_32nds&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/32, steer_axle=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;steer_note&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;violations&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This block is where I learned the most during testing. TRK-1042 in the sample data reports twelve percent brake pads on a Class 8 unit with steer-axle notes and three and a half thirty-seconds tread. Both brake critical and tire tread warning fire. Without the steer note, tread at three and a half would only trigger a warning, not critical. Dispatcher context changed the outcome, which is exactly the behavior I wanted.&lt;/p&gt;

&lt;p&gt;The compliance policy agent wraps policy search across detected violations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;compliance_policy_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;search_policies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vehicle_class&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vehicle_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;refs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CompliancePolicyAgent: attached &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;refs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; policy references&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;refs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The action agent returns both steps and response hours. That pairing is what makes the output feel operational instead of academic.&lt;/p&gt;

&lt;h3&gt;
  
  
  LangGraph workflow
&lt;/h3&gt;

&lt;p&gt;The graph is small but worth showing because it is the spine of the PoC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_compliance_graph&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StateGraph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ComplianceState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;load&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_load_batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;process&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_process_next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summarize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_summarize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_entry_point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;load&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;load&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;process&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_conditional_edges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;process&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;_should_continue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;process&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;process&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summarize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summarize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summarize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;END&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each pass through &lt;code&gt;process&lt;/code&gt; validates one maintenance record and increments the index. When the index reaches the batch length, the graph routes to &lt;code&gt;summarize&lt;/code&gt;, which is a hook I left open for aggregate analytics such as average confidence or violation histograms.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;run_batch_compliance&lt;/code&gt; computes urgency counts and violation distributions after the graph finishes. That summary feeds both the Rich CLI table and the Matplotlib chart in &lt;code&gt;src/fleetguard/analytics.py&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;BatchComplianceReport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;fleet_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fleet_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;processed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;emergency_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;UrgencyLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EMERGENCY&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;high_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;UrgencyLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HIGH&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;medium_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;UrgencyLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MEDIUM&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;low_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;UrgencyLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LOW&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;normal_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;UrgencyLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NORMAL&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;summary_stats&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;avg_confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;avg_response_hours&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;response_hours&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;violation_distribution&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;violation_distribution&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The summary statistics feed the dashboard cards and the Matplotlib chart in the README. When I first omitted &lt;code&gt;violation_distribution&lt;/code&gt;, the UI felt empty even though per-row validation was correct. Aggregates matter for human scanability.&lt;/p&gt;

&lt;h3&gt;
  
  
  FastAPI surface
&lt;/h3&gt;

&lt;p&gt;The API is intentionally thin. It validates payloads, calls &lt;code&gt;run_batch_compliance&lt;/code&gt;, and returns a &lt;code&gt;BatchComplianceReport&lt;/code&gt;. The sample endpoint loads &lt;code&gt;data/sample_fleet_records.json&lt;/code&gt;, which describes eight records across Class 7, Class 8, light-duty, and refrigerated units: TRK-1042 with critical brake wear and steer-axle tire concern, TRK-2087 cleared for regional route, REF-3301 with overdue DOT inspection, VAN-0091 with missing maintenance paperwork, TRK-1156 with critical brakes, critical tires, ELD hours exceeded, open defects, and odometer discrepancy, TRK-1893 with DOT window closing and PM overdue, TRK-2204 nominal for long-haul, and REF-3412 routine refrigerated check.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/validate/sample&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_sample&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;JSONResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_load_sample_records&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_batch_compliance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JSONResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_dump_json&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/validate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_batch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;MaintenanceRecord&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;JSONResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_batch_compliance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JSONResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_dump_json&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wired CORS permissively because this is a local PoC dashboard, not a public deployment. If I hardened the experiment, authentication and fleet tenancy would come first.&lt;/p&gt;

&lt;h3&gt;
  
  
  CLI presentation layer
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;main.py&lt;/code&gt; uses Rich tables because dispatchers often work from tabular summaries. I print fleet name, urgency counts, and per-record rows with color-coded urgency. The CLI became the source of truth for the GIF animation frames, which keeps marketing assets honest.&lt;/p&gt;

&lt;h3&gt;
  
  
  What surprised me during implementation
&lt;/h3&gt;

&lt;p&gt;When I first ran the batch, refrigerated unit REF-3301 with three hundred seventy-two days since inspection scored emergency urgency combined with brake wear warning, not because brakes were critical but because overdue DOT inspection shares the emergency ladder with critical mechanical wear. I tightened the urgency agent so inspection overdue always surfaces at the top regardless of secondary warnings. Another surprise: TRK-1156 accumulated five simultaneous violations yet still produced a coherent action list because each violation maps to a distinct remediation step rather than collapsing into a generic alert.&lt;/p&gt;

&lt;h3&gt;
  
  
  Urgency mapping logic
&lt;/h3&gt;

&lt;p&gt;The urgency agent applies a priority ladder I sketched on paper before coding. Critical brake wear, critical tire tread, and overdue DOT inspection always win emergency status. ELD hours exceeded and open defect reports escalate to high. Warning-level brake and tire issues with PM due soon land at medium. Documentation missing alone stays low. I wrote the ladder as explicit conditionals rather than a scored formula because explainability beat marginal accuracy in this PoC.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;urgency_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ComplianceViolation&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;UrgencyLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;violations&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ComplianceViolation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BRAKE_WEAR_CRITICAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ComplianceViolation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TIRE_TREAD_CRITICAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ComplianceViolation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOT_INSPECTION_OVERDUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;UrgencyLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EMERGENCY&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ComplianceViolation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ELD_HOURS_EXCEEDED&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;UrgencyLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HIGH&lt;/span&gt;
    &lt;span class="c1"&gt;# ... additional branches for defects, PM, warnings
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Readers forking the repo should treat this function as the first customization point. Every fleet operates with different risk appetite, and swapping urgency tables is easier than rewriting an LLM prompt.&lt;/p&gt;

&lt;h3&gt;
  
  
  Confidence scoring rationale
&lt;/h3&gt;

&lt;p&gt;Confidence is not model probability here; it is a heuristic transparency score. Base confidence starts at 0.74. Multiple violations add 0.07. Emergency or high urgency adds 0.06. A complete maintenance log adds 0.04. The cap is 0.97 because I never want a deterministic rules engine pretending certainty it cannot justify. When I present confidence in the CLI, I intend it as a sort key for human review, not as a statistical guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 4 — Operations, Reflections, and Next Steps
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Let's Setup
&lt;/h2&gt;

&lt;p&gt;Step-by-step details can be found at the repository README: &lt;a href="https://github.com/aniket-work/FleetGuard-AI" rel="noopener noreferrer"&gt;https://github.com/aniket-work/FleetGuard-AI&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Locally, the setup path I used looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Clone the repository and create a virtual environment inside the project root.&lt;/li&gt;
&lt;li&gt;Install dependencies from &lt;code&gt;requirements.txt&lt;/code&gt;, which pins LangGraph, FastAPI, Rich, and Matplotlib among others.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;python main.py&lt;/code&gt; to validate the sample JSON batch and print the Rich summary table.&lt;/li&gt;
&lt;li&gt;Optional: run &lt;code&gt;python main.py --chart&lt;/code&gt; to generate &lt;code&gt;images/validation-stats-chart.png&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Optional: start &lt;code&gt;uvicorn fleetguard.api.server:app --app-dir src --reload&lt;/code&gt; and open the dashboard at port 8000.&lt;/li&gt;
&lt;li&gt;Optional: execute &lt;code&gt;pytest tests/ -v&lt;/code&gt; to confirm critical brake detection and batch confidence thresholds.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I recommend keeping the virtual environment inside the repository folder for PoCs like this so paths to &lt;code&gt;data/sample_fleet_records.json&lt;/code&gt; remain predictable when you run scripts from different working directories.&lt;/p&gt;

&lt;p&gt;Environment variables are not required for the default demo. If you extend the project with embedding-based retrieval or an external LLM provider, add keys through a &lt;code&gt;.env&lt;/code&gt; file but do not commit secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Run
&lt;/h2&gt;

&lt;p&gt;Running &lt;code&gt;python main.py&lt;/code&gt; against the bundled dataset typically reports eight processed records with three emergency, one medium, one low, and three normal urgency findings in my latest run. Emergency items included TRK-1042 with critical brake wear and steer-axle tire concern, REF-3301 with overdue DOT inspection beyond three hundred sixty-five days, and TRK-1156 with critical brakes at eight percent pad life, critical tire tread at one point eight thirty-seconds, ELD hours at sixty-two point five, two open defects, and an odometer discrepancy note. Average confidence landed around eighty-three percent across the mixed batch.&lt;/p&gt;

&lt;p&gt;The CLI output ends with an ASCII-friendly table that mirrors what I animated in the repository GIF. That was intentional: I wanted the marketing asset and the actual tool output to tell the same story.&lt;/p&gt;

&lt;p&gt;For API mode, POST &lt;code&gt;/api/validate/sample&lt;/code&gt; returns JSON containing per-record &lt;code&gt;recommended_actions&lt;/code&gt; and &lt;code&gt;policy_refs&lt;/code&gt;. The dashboard renders urgency cards and a results table without a separate frontend build step.&lt;/p&gt;

&lt;p&gt;To generate analytics assets, run &lt;code&gt;python main.py --chart&lt;/code&gt;. It writes &lt;code&gt;images/validation-stats-chart.png&lt;/code&gt;, which I included in the README so GitHub visitors see quantitative behavior immediately.&lt;/p&gt;

&lt;p&gt;When I demo this PoC to myself after a break, I use a three-step smoke path: CLI batch, pytest, then dashboard POST. That sequence exercises the graph, validation rules, and HTTP layer without needing external services. If all three pass, I trust the repository state enough to publish diagrams or write about it.&lt;/p&gt;

&lt;p&gt;For readers who prefer API exploration with curl, &lt;code&gt;curl -X POST http://localhost:8000/api/validate/sample&lt;/code&gt; returns the full &lt;code&gt;BatchComplianceReport&lt;/code&gt; JSON. I often pipe that output through &lt;code&gt;jq&lt;/code&gt; to inspect a single record's &lt;code&gt;recommended_actions&lt;/code&gt; array and verify policy references attached correctly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Vehicle-class threshold tables I encoded
&lt;/h3&gt;

&lt;p&gt;The business rules in this PoC are intentionally visible as Python dictionaries rather than buried in prompts. Class 8 brake pad critical threshold is twenty percent with warning at thirty percent. Class 7 critical is fifteen percent. Light-duty critical is ten percent because smaller vehicles carry different wear profiles. Refrigerated units use an eighteen percent critical threshold and a fifteen thousand mile PM interval instead of twenty five thousand. Tire tread critical defaults to two thirty-seconds with steer-axle notes raising the effective minimum to four thirty-seconds. I chose these numbers from publicly available FMCSA inspection guidance and simplified them for demo clarity. They are not fleet-specific standards.&lt;/p&gt;

&lt;p&gt;ELD hour limits matter because hours-of-service violations carry fines independent of mechanical condition. When TRK-1156 reports sixty-two point five driving hours in seven days alongside mechanical emergencies, multiple agents fire without one silently overriding another. That combination is exactly the kind of multi-signal case I wanted LangGraph to handle.&lt;/p&gt;

&lt;h3&gt;
  
  
  Human-in-the-loop considerations
&lt;/h3&gt;

&lt;p&gt;Even in a deterministic PoC, I reserved mental space for human review. Emergency readings should page a fleet manager, not close the loop autonomously. LangGraph supports &lt;code&gt;interrupt_before&lt;/code&gt; on nodes, and I would place that interrupt before any future "execute out-of-service order" node if I connected to real dispatch systems. Today the action agent only returns text recommendations. That boundary matters ethically and practically.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8qkcaxvb6n3h1hgwgpt1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8qkcaxvb6n3h1hgwgpt1.png" alt="Stats Chart" width="800" height="346"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge Cases and Testing Philosophy
&lt;/h2&gt;

&lt;p&gt;I wrote three pytest cases covering critical brake emergency detection, clean record normal clearance, and batch length with confidence thresholds. Tests encode what I care about in this PoC: critical brake wear must never be downgraded quietly, and every normal result must include a dispatch clearance action.&lt;/p&gt;

&lt;p&gt;Ambiguous dispatcher notes remain a weakness. A note mentioning "steer" in an unrelated context might still tighten tire thresholds if phrasing overlaps keywords. In production I would route low-confidence classifications to a human queue. Here, I expose &lt;code&gt;agent_trace&lt;/code&gt; lists to make that queue possible later.&lt;/p&gt;

&lt;p&gt;Vehicle class tables only cover four classes in this demo. A real fleet with mixed power units and trailers would need composite rules. That limitation is acceptable in a demo but worth documenting honestly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Walking Through a Sample Record
&lt;/h2&gt;

&lt;p&gt;To make the pipeline concrete, consider record &lt;code&gt;REC-005&lt;/code&gt; from the sample dataset: Class 8 tractor TRK-1156 reports brake pads at eight percent, tire tread at one point eight thirty-seconds, ELD hours at sixty-two point five, two open defects, and dispatcher notes mentioning odometer discrepancy and driver hours over limit. When I feed this batch through the graph, the brake tire agent flags critical brake wear below the Class 8 twenty percent floor and critical tire tread below the two thirty-seconds minimum. The ELD hours agent flags hours exceeded above sixty. The defect documentation agent flags open defects and odometer gap from notes. The compliance policy agent retrieves FMCSA references for each violation category. The action agent responds with immediate pull-from-dispatch, priority brake service, tire replacement, mandatory thirty-four-hour restart, defect resolution, and odometer audit steps with a two-hour response window.&lt;/p&gt;

&lt;p&gt;That end-to-end path takes milliseconds in my local runs, but the value is not speed. The value is repeatability. Every record in the batch gets the same structured treatment, which means I can diff reports across code versions when I tune heuristics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Policy Knowledge Base Design
&lt;/h2&gt;

&lt;p&gt;I modeled the knowledge layer as a list of &lt;code&gt;PolicyChunk&lt;/code&gt; dataclass rows rather than jumping straight to ChromaDB. Each chunk stores a code, title, body, vehicle class tag, and category. The search function tokenizes query and body text, counts overlap, and applies class and category boosts. This is intentionally primitive. From my experience building RAG demos, people sometimes spend days on embedding pipelines before validating whether retrieval inputs are structured correctly. Here, the interface is stable: &lt;code&gt;search_policies(query, vehicle_class, category, top_k)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If I swap in embeddings later, agents above the knowledge layer stay unchanged. That separation mattered to me because it mirrors how I would approach a production migration: nail schemas and agent IO first, upgrade retrieval second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monolithic Prompt Versus Specialist Agents
&lt;/h2&gt;

&lt;p&gt;Early in the experiment I tried a single prompt that asked for violations, urgency, citations, and actions in one JSON blob. It failed in boring ways: urgency would be conservative when citations were verbose, and ELD hour violations were mislabeled as PM overdue when mileage notes appeared in the same sentence. Splitting responsibilities into agents eliminated most of that cross-talk. Each agent receives only the fields it needs, writes to &lt;code&gt;agent_trace&lt;/code&gt;, and returns a typed value the coordinator merges.&lt;/p&gt;

&lt;p&gt;This aligns with how I think about LangGraph more generally. Nodes are functions with narrow contracts. Edges express control flow a dispatcher could whiteboard. When I revisit the monolithic prompt idea, it will be as a final summarization layer, not as the core reasoning engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dashboard and Visualization Choices
&lt;/h2&gt;

&lt;p&gt;The HTML dashboard uses a dark theme with urgency-colored badges because fleet monitoring tools I have seen in demos often default to sterile white tables that hide urgency. I wanted emergency items to visually pop. The Matplotlib chart uses horizontal bars for urgency and top violation categories. Static charts export cleanly to README, which helps GitHub visitors who never run the code still grasp batch behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance and Operational Notes
&lt;/h2&gt;

&lt;p&gt;The sample batch of eight records completes in under a second on my laptop, including graph orchestration overhead. I did not optimize for throughput because validation batches in this PoC are tiny. If batches grew to thousands of historical records, I would parallelize per-record validation outside LangGraph or shard by fleet while keeping the same agent functions.&lt;/p&gt;

&lt;p&gt;Memory footprint stays small because records are pydantic objects in a list, not streamed from a telematics broker. Logging is print-based in the CLI and trace lists in results. A next step would be structured JSON logs with record IDs for observability, but I skipped that to keep the repository approachable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Theory Behind Response Hours
&lt;/h2&gt;

&lt;p&gt;Response hours encode how quickly I believe a fleet manager must show correction before roadside risk or citation exposure grows. Critical brake wear and overdue DOT inspection map to two hours in my heuristics because out-of-service conditions cannot wait a business week. Documentation gaps map to seventy-two hours because they are serious but not always an immediate roadside event. These numbers are not organizational standards; they are PoC placeholders where I tried to mirror operational urgency I observed in fleet compliance materials.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integration Points I Deliberately Left Open
&lt;/h2&gt;

&lt;p&gt;The FastAPI layer returns pydantic-serialized JSON ready for webhooks, SMS workers, or transportation management dashboards I did not build. &lt;code&gt;agent_trace&lt;/code&gt; arrays are the extension point for human review queues: a UI could highlight low-confidence rows or records where mechanical and regulatory agents disagreed with a prior human label. The compliance policy agent returns string citations today; tomorrow it could return structured objects with URLs to regulatory documents.&lt;/p&gt;

&lt;p&gt;I also left authentication out entirely. If this ever faced real fleet credentials or tenant data, isolation and role-based access would precede any model upgrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ethics and Boundaries
&lt;/h2&gt;

&lt;p&gt;Automating fleet compliance validation touches driver safety, carrier CSA scores, and economic outcomes for fleet operators. I want to be explicit: this PoC does not place out-of-service orders, does not lock ELD devices, and does not connect to live dispatch control planes. It sorts records you give it. If someone deployed a derivative system, human review on emergency outcomes would be non-negotiable in my view, and rule changes would require traceable versioning.&lt;/p&gt;

&lt;p&gt;I also avoided implying employer affiliation. This build lives entirely in my personal GitHub namespace as an experiment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Future Roadmap I Would Explore
&lt;/h2&gt;

&lt;p&gt;If I continue this PoC, my next steps would be:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Replace lexical policy search with embeddings stored in Chroma or pgvector, then measure citation precision against a held-out record set.&lt;/li&gt;
&lt;li&gt;Add an LLM summarization step that rewrites recommended actions into dispatcher runbook language while keeping structured JSON underneath.&lt;/li&gt;
&lt;li&gt;Introduce vehicle maintenance history so repeat brake wear patterns escalate urgency automatically.&lt;/li&gt;
&lt;li&gt;Export compliance ticket drafts with policy footnotes for transportation management systems.&lt;/li&gt;
&lt;li&gt;Build a feedback endpoint where a human reviewer marks agent mistakes, creating training data for later fine-tuning.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of those are implemented here. They are the natural extension points I noted while writing the graph skeleton.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I would not change
&lt;/h3&gt;

&lt;p&gt;If I rebuilt the PoC from scratch, I would keep three decisions exactly as they are: strict pydantic schemas at the boundary, separate specialist modules instead of one prompt file, and &lt;code&gt;agent_trace&lt;/code&gt; on every result. Those choices cost almost nothing in lines of code but paid off every time I debugged a surprising urgency label.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reader exercises
&lt;/h3&gt;

&lt;p&gt;If you fork the repository, here are three exercises I found useful while learning:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add a new record to &lt;code&gt;sample_fleet_records.json&lt;/code&gt; with ambiguous dispatcher notes and write a test that asserts your expected urgency.&lt;/li&gt;
&lt;li&gt;Introduce a sixth agent that drafts a plain-language summary paragraph per record without altering structured fields.&lt;/li&gt;
&lt;li&gt;Swap lexical policy search for an embedding model and compare citation precision on ten hand-labeled examples.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each exercise touches a different production concern: evaluation, human-readable output, and retrieval quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparing Batch Outputs Across Iterations
&lt;/h2&gt;

&lt;p&gt;One habit I kept from prior agent experiments is saving JSON reports when iterating heuristics. FleetGuard-AI supports &lt;code&gt;--json-out&lt;/code&gt; on the CLI. Diffing two JSON files after a brake threshold tweak shows exactly which record IDs changed urgency or response hours. That practice sounds tedious, but it prevented regressions when I adjusted Class 8 brake critical percentages. Without structured output, I would rely on eyeballing terminal tables, which does not scale past a handful of records.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Fleet Compliance and Not Another Domain
&lt;/h2&gt;

&lt;p&gt;I rotate domains in my personal agent experiments to avoid repeating myself. Fleet maintenance sat outside my recent project history and offered a rich rule surface: federal inspection windows, mechanical wear curves, hours-of-service limits, defect documentation, and regulatory policy all intersect in one record. That intersection is what makes multi-agent validation interesting. A single monolithic classifier would need to simultaneously reason about mechanics, regulation, and operations. Splitting agents let me test each concern independently and combine results with traceable provenance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;FleetGuard-AI started as a question about dispatch triage labor, not as a pitch for autonomous fleet management. What I ended up with is a compact LangGraph workflow that feels honest about its limits: deterministic agents, explicit traces, readable tests, and an API that could swap underneath without rewriting business logic.&lt;/p&gt;

&lt;p&gt;From my experience, the hardest part of multi-agent tutorials is not drawing boxes on a whiteboard. It is choosing a domain where evaluation is grounded. Maintenance records gave me that grounding. When the urgency agent mistyped an ELD hours violation, tests failed or traces looked wrong immediately. When mechanical heuristics worked, the CLI table matched my own gut ranking.&lt;/p&gt;

&lt;p&gt;If you are building your own orchestration experiments, steal the structure more than the rules. Keep schemas strict, keep agents narrow, keep orchestration visible, and keep humans in the loop for emergency outcomes. The repository at &lt;a href="https://github.com/aniket-work/FleetGuard-AI" rel="noopener noreferrer"&gt;https://github.com/aniket-work/FleetGuard-AI&lt;/a&gt; is there if you want to run the same path I did and remix it for a completely different operational domain.&lt;/p&gt;

&lt;p&gt;One last reflection: agent hype often focuses on autonomy. This project convinced me that disciplined validation is the more interesting problem. Fleet records do not need a chatty assistant; they need reliable sorting, traceable reasoning, and fast handoff to humans when stakes are high. LangGraph helped me express that validation story in code without pretending the PoC is more than an experiment.&lt;/p&gt;

&lt;p&gt;I will keep iterating on personal agent projects like this because each one teaches a reusable pattern. FleetGuard-AI's pattern is narrow agents, explicit graph state, honest tests, and dashboards that mirror terminal truth. That combination is what I would carry into the next domain, whatever it ends up being.&lt;/p&gt;

&lt;p&gt;Cover animation for readers who prefer visual summaries:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F14nlq9092ymrbtx7j05g.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F14nlq9092ymrbtx7j05g.gif" alt="Cover Animation" width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclaimer
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Tags: python, langgraph, ai, fleet&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>langgraph</category>
      <category>ai</category>
      <category>fleet</category>
    </item>
    <item>
      <title>Building an Intelligent Aquaculture Pond Health Validator with LangGraph</title>
      <dc:creator>Aniket Hingane</dc:creator>
      <pubDate>Tue, 30 Jun 2026 02:09:18 +0000</pubDate>
      <link>https://dev.to/exploredataaiml/building-an-intelligent-aquaculture-pond-health-validator-with-langgraph-2inn</link>
      <guid>https://dev.to/exploredataaiml/building-an-intelligent-aquaculture-pond-health-validator-with-langgraph-2inn</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxnk5gimy0vaabnxglyvt.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxnk5gimy0vaabnxglyvt.gif" alt="Cover" width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How I Automated Water Quality Triage Using Multi-Agent Business Rules&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Part 1 — Context and Foundations
&lt;/h2&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I built AquaGuard-AI as a personal proof-of-concept to explore a problem I kept noticing while experimenting with operational monitoring patterns: aquaculture pond sensor readings are easy to collect, but the hard part is deciding what each reading means when species rules, feeding schedules, calibration age, and farm policy all interact. This experimental project routes pond readings through a LangGraph-orchestrated multi-agent pipeline backed by FastAPI and a lightweight aquaculture policy knowledge index. Each reading passes through sensor validation, species threshold checks, feeding window gates, compliance retrieval, and action generation before landing in a structured report with urgency levels and agent traces. The full source code lives at &lt;a href="https://github.com/aniket-work/AquaGuard-AI" rel="noopener noreferrer"&gt;https://github.com/aniket-work/AquaGuard-AI&lt;/a&gt;. From my experience, the most interesting takeaway is not any single agent prompt, but how graph-based orchestration keeps specialist validation steps composable without turning business logic into spaghetti. This write-up documents my design choices, the code I wrote along the way, and what I would change if I extended the PoC further.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;A few weeks ago I started paying closer attention to what happens after a pond probe reports dissolved oxygen at 2.4 mg/L at six in the morning. From my observation reading aquaculture operations forums and anonymized sensor exports, the number itself is rarely the whole story. A field operator still has to answer harder questions. Is this critical for tilapia or merely concerning for catfish? Does a scheduled feed batch make the situation worse? Is the probe trustworthy given calibration age? Which farm policy clause applies, and how quickly should someone respond before stock loss?&lt;/p&gt;

&lt;p&gt;I wanted to test whether multi-agent orchestration could compress that triage work without pretending to replace human judgment. AquaGuard-AI is the result of that experiment. It is not production software, not something I deployed at any employer, and not a claim about how aquaculture farms should operate. It is a solo PoC I built to learn LangGraph patterns in a domain that felt concrete and underserved in agent tutorials.&lt;/p&gt;

&lt;p&gt;The architecture rhymes with other validation experiments I have tried: specialist agents, a coordinator graph, a knowledge layer for policy retrieval, and a thin API for integration. I deliberately chose aquaculture pond health rather than chatbots or document summarization because those demos are overrepresented, and because water quality incidents have crisp numeric inputs and operational outputs that make evaluation honest.&lt;/p&gt;

&lt;p&gt;What pulled me toward this domain specifically was the asymmetry between measurement and response. Modern farms already emit dissolved oxygen, pH, temperature, ammonia, and turbidity readings from in-pond probes. The gap I noticed was structured validation: turning raw readings into urgency labels, species-aware anomaly tags, policy citations, and recommended actions that a field operator could accept or override quickly. I wondered whether explicit agent roles could mirror that mental sorting without hiding reasoning inside a black-box model.&lt;/p&gt;

&lt;p&gt;I also wanted a use case where mistakes are obvious. If an agent labels a 2.4 mg/L reading as normal for tilapia grow-out, tests should scream. That clarity helped me iterate faster than I would have in a vague business insights demo where quality is subjective.&lt;/p&gt;

&lt;p&gt;From where I stand, aquaculture validation shares a structural pattern with other enterprise-style agent experiments I have built: you receive structured events, apply domain-specific business rules in layers, attach policy citations, and emit operational recommendations with explicit urgency. I did not copy any prior tutorial wholesale. Instead, I asked what "validation" means when the inventory is live fish rather than shopping carts, then designed agents around that question.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's This Article About?
&lt;/h2&gt;

&lt;p&gt;This article walks through how I designed and implemented AquaGuard-AI, a multi-agent system that ingests batches of pond sensor readings and emits validation reports containing anomaly labels, urgency scores, aquaculture policy references, recommended interventions, and response hour estimates. You will see the LangGraph state machine I used, the specialist agents I wrote, the policy knowledge module that acts as a stand-in for retrieval-augmented generation, and the FastAPI surface that exposes the workflow to a simple dashboard.&lt;/p&gt;

&lt;p&gt;I also cover setup and execution steps so you can reproduce the PoC locally, and I share edge cases I discovered while testing, including feeding-window conflicts that only appear when operator notes mention scheduled feed batches. Throughout, I frame conclusions as personal observations from an experimental build rather than authoritative guidance for regulated aquaculture environments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;p&gt;I kept the stack intentionally small. Complexity in agent systems often hides in dependencies long before it hides in prompts, and I wanted to feel every layer.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Python 3.11&lt;/strong&gt; as the runtime. Strong typing with Pydantic models made pond reading batches easy to validate at API boundaries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LangGraph&lt;/strong&gt; for orchestration. I considered a hand-rolled coordinator loop, but LangGraph gave me explicit state transitions and a graph I could diagram without lying.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FastAPI&lt;/strong&gt; for HTTP endpoints and to serve a minimal HTML dashboard from the same process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pydantic v2&lt;/strong&gt; for schemas such as &lt;code&gt;PondReading&lt;/code&gt;, &lt;code&gt;ValidationResult&lt;/code&gt;, and &lt;code&gt;BatchValidationReport&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rich&lt;/strong&gt; for CLI tables. Terminal output matters in field operations workflows because many farm tools still live partly in terminals and JSON exports.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Matplotlib&lt;/strong&gt; for urgency and anomaly distribution charts. I wanted at least one visual summary of learned validation statistics, not because the charts are fancy, but because they make batch behavior obvious at a glance.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I skipped a full vector database in this PoC. A lexical policy index was enough to prove the RAG insertion point, and it kept the repository approachable for readers who want to clone and run in ten minutes.&lt;/p&gt;

&lt;p&gt;As per my experience shipping small services, I optimize for clone-to-green time in PoCs. Every extra infrastructure component is a reader who bounces at step four of the README. LangGraph plus FastAPI plus a JSON fixture gets people to a colored terminal table quickly. That table is the emotional payoff that convinces someone to read the rest of the design sections.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Role in AquaGuard-AI&lt;/th&gt;
&lt;th&gt;Why I chose it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LangGraph&lt;/td&gt;
&lt;td&gt;Per-reading validation loop&lt;/td&gt;
&lt;td&gt;Makes orchestration visible and testable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FastAPI&lt;/td&gt;
&lt;td&gt;HTTP plus static dashboard&lt;/td&gt;
&lt;td&gt;Single process dev experience&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pydantic&lt;/td&gt;
&lt;td&gt;Schema guardrails&lt;/td&gt;
&lt;td&gt;Prevents malformed reading batches early&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rich&lt;/td&gt;
&lt;td&gt;CLI tables&lt;/td&gt;
&lt;td&gt;Matches field-operator friendly output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Matplotlib&lt;/td&gt;
&lt;td&gt;Urgency and anomaly charts&lt;/td&gt;
&lt;td&gt;Quant summary for README and article&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F19lb0nnu6k9sc32wbcyn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F19lb0nnu6k9sc32wbcyn.png" alt="Architecture" width="800" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2 — Design Rationale
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Why Read It?
&lt;/h2&gt;

&lt;p&gt;If you are experimenting with LangGraph and tired of toy chatbots, aquaculture pond validation offers a disciplined sandbox. Inputs are structured sensor readings with species metadata. Outputs are enums, policy codes, and action lists. That makes it easy to tell when an agent misbehaved without hand-waving about subjective quality.&lt;/p&gt;

&lt;p&gt;From my perspective, three ideas in this PoC transfer to other domains:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Specialist agents beat monolithic prompts&lt;/strong&gt; when each step has different failure modes. Species threshold scoring and feeding schedule gates should not share one prompt context window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graph orchestration documents operational process&lt;/strong&gt; better than nested if-statements. Farm operators already think in workflows; LangGraph mirrors that honestly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A thin API unlocks multiple clients&lt;/strong&gt; with one workflow. I used both a Rich CLI and a browser dashboard against the same validation function.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The GitHub repository at &lt;a href="https://github.com/aniket-work/AquaGuard-AI" rel="noopener noreferrer"&gt;https://github.com/aniket-work/AquaGuard-AI&lt;/a&gt; includes tests, sample data, diagrams, and a title animation GIF generated from terminal output plus a dashboard preview. Clone it, run &lt;code&gt;python main.py&lt;/code&gt;, and you will see the same ASCII table aesthetic I embedded in the article cover animation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Design
&lt;/h2&gt;

&lt;p&gt;Before writing code I sketched the lifecycle of a single pond reading. A probe reports dissolved oxygen, pH, temperature, ammonia, turbidity, biomass density, and calibration age. A validation system must answer five questions: is the sensor trustworthy, do species thresholds permit current chemistry, does a feeding window conflict exist, which farm policy clauses apply, and what intervention urgency is appropriate. Those questions map cleanly to five agent responsibilities plus a coordinator.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy2sww20q0k5ybbhf8dkq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy2sww20q0k5ybbhf8dkq.png" alt="Sequence" width="799" height="326"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Coordinator and state
&lt;/h3&gt;

&lt;p&gt;The coordinator owns batch progress. LangGraph state carries the reading list, accumulated results, farm name, and a numeric index. I chose explicit index-based iteration rather than recursive graph tricks because it made debugging easier when a single reading produced surprising output.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sensor validation agent
&lt;/h3&gt;

&lt;p&gt;The sensor agent checks calibration age and dissolved oxygen criticality before species rules run. Probes older than forty-five days flag &lt;code&gt;sensor_calibration_due&lt;/code&gt;. Dissolved oxygen below species-critical floors triggers &lt;code&gt;dissolved_oxygen_critical&lt;/code&gt;. I put calibration checks first because, in my opinion, acting on stale probe data is worse than acting slowly on fresh data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Species rules agent
&lt;/h3&gt;

&lt;p&gt;Species is where aquaculture business logic actually lives. Tilapia, shrimp, and catfish each carry distinct dissolved oxygen minimums, pH bands, ammonia ceilings, and temperature ranges. Shrimp tolerates narrower ammonia windows than tilapia. Catfish temperature stress appears above thirty-two Celsius. Encoding these as lookup tables made pytest assertions straightforward.&lt;/p&gt;

&lt;h3&gt;
  
  
  Feeding schedule agent
&lt;/h3&gt;

&lt;p&gt;This agent encodes an operational gate I rarely see in generic IoT demos: do not feed when water quality fails species minimums. If operator notes mention a scheduled feed batch while dissolved oxygen or ammonia violates thresholds, the agent flags &lt;code&gt;feeding_window_conflict&lt;/code&gt;. That single rule prevented several false "all clear" outcomes in my sample batch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compliance agent
&lt;/h3&gt;

&lt;p&gt;This agent queries a small aquaculture policy chunk index. Each chunk includes a code like &lt;code&gt;AQ-TIL-001&lt;/code&gt;, title, body, and species tag. Search is lexical overlap with species and category boosts. It is a deliberate stand-in for embedding retrieval, but the interface is stable: pass description and species, receive citation strings.&lt;/p&gt;

&lt;h3&gt;
  
  
  Action and urgency agents
&lt;/h3&gt;

&lt;p&gt;Finally, the urgency agent maps anomaly sets to emergency, high, medium, low, or normal levels. The action agent converts urgency into recommended interventions and response hour counts. Critical dissolved oxygen triggers emergency aeration steps with a one-hour response window. Feeding conflicts map to high urgency with hold-feed instructions.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa0p6emifu0yc0878nwd3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa0p6emifu0yc0878nwd3.png" alt="Workflow" width="541" height="1347"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  API and clients
&lt;/h3&gt;

&lt;p&gt;FastAPI exposes &lt;code&gt;/api/validate/sample&lt;/code&gt; for the bundled dataset and &lt;code&gt;/api/validate&lt;/code&gt; for custom payloads. The root route serves a dark-themed dashboard that renders urgency cards and a results table after a POST call. I put the frontend in a single HTML file to avoid Node build tooling in a PoC README.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3 — Implementation Deep Dive
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Let's Get Cooking
&lt;/h2&gt;

&lt;p&gt;Here is where I translate the design into code. I split the implementation into schema models, specialist agents, the LangGraph workflow, and the API layer. I wrote each block to be readable in isolation because, in my experience, agent repos rot quickly when everything lives in one thousand-line module.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data models
&lt;/h3&gt;

&lt;p&gt;I started with Pydantic models because pond readings arrive from JSON exports and IoT gateways in real life, and I wanted validation before any agent touched the data. Strong typing also made FastAPI response models trivial to declare.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UrgencyLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;EMERGENCY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;emergency&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;HIGH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;MEDIUM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;LOW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;NORMAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;normal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PondReading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;reading_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;pond_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;species&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SpeciesType&lt;/span&gt;
    &lt;span class="n"&gt;dissolved_oxygen_mg_l&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;25.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;14.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;temperature_c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mf"&gt;5.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;45.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ammonia_mg_l&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;10.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;turbidity_ntu&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;500.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;biomass_kg_per_m3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;50.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sensor_calibration_days_ago&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;365&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;recorded_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;operator_notes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These models became the contract between CLI, API, and agents. Keeping enums strict prevented silent string drift in urgency labels when I generated charts later. I designed &lt;code&gt;operator_notes&lt;/code&gt; as a first-class field because feeding schedule logic depends on human context that pure chemistry cannot capture.&lt;/p&gt;

&lt;h3&gt;
  
  
  Specialist agents
&lt;/h3&gt;

&lt;p&gt;Each agent appends to an &lt;code&gt;agent_trace&lt;/code&gt; list so I could explain decisions in the CLI and in future audit logs. The sensor validation agent checks calibration and dissolved oxygen floors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sensor_validation_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PondReading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ReadingAnomaly&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;anomalies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ReadingAnomaly&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sensor_calibration_days_ago&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;anomalies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ReadingAnomaly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SENSOR_CALIBRATION_DUE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dissolved_oxygen_mg_l&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;SPECIES_DO_CRITICAL&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;species&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;anomalies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ReadingAnomaly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DISSOLVED_OXYGEN_CRITICAL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dissolved_oxygen_mg_l&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;SPECIES_DO_MIN&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;species&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;anomalies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ReadingAnomaly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DISSOLVED_OXYGEN_LOW&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SensorValidationAgent: found &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;anomalies&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; sensor anomalies&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;anomalies&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Species rules encode the aquaculture intuition I mentioned earlier. Shrimp ammonia above 0.25 mg/L becomes &lt;code&gt;ammonia_elevated&lt;/code&gt;. Tilapia pH outside 6.5 to 8.5 becomes &lt;code&gt;ph_out_of_range&lt;/code&gt;. I put it this way because species mistakes are costlier than sensor mistakes: they directly influence mortality risk estimates.&lt;/p&gt;

&lt;p&gt;The feeding schedule agent inspects operator notes for feed keywords:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;feed_requested&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;kw&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;kw&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feed scheduled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feeding window&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;distribute feed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feed batch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;feed_requested&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;do_low&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;ammonia_high&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;anomalies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ReadingAnomaly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FEEDING_WINDOW_CONFLICT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This block is short but important. It encodes a business rule that numeric thresholds alone cannot express.&lt;/p&gt;

&lt;p&gt;The compliance agent wraps policy search:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;compliance_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;anomalies&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;search_policies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;species&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;species&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;refs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ComplianceAgent: attached &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;refs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; policy references&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;refs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The action agent returns both steps and response hours. That pairing is what makes the output feel operational instead of academic.&lt;/p&gt;

&lt;h3&gt;
  
  
  LangGraph workflow
&lt;/h3&gt;

&lt;p&gt;The graph is small but worth showing because it is the spine of the PoC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_validation_graph&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StateGraph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ValidationState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;load&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_load_batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;process&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_process_next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summarize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_summarize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_entry_point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;load&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;load&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;process&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_conditional_edges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;process&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;_should_continue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;process&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;process&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summarize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summarize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summarize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;END&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each pass through &lt;code&gt;process&lt;/code&gt; validates one pond reading and increments the index. When the index reaches the batch length, the graph routes to &lt;code&gt;summarize&lt;/code&gt;, which is a hook I left open for aggregate analytics such as average confidence or anomaly histograms.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;run_batch_validation&lt;/code&gt; computes urgency counts and anomaly distributions after the graph finishes. That summary feeds both the Rich CLI table and the Matplotlib chart in &lt;code&gt;src/aquaguard/analytics.py&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  FastAPI surface
&lt;/h3&gt;

&lt;p&gt;The API is intentionally thin. It validates payloads, calls &lt;code&gt;run_batch_validation&lt;/code&gt;, and returns a &lt;code&gt;BatchValidationReport&lt;/code&gt;. The sample endpoint loads &lt;code&gt;data/sample_pond_readings.json&lt;/code&gt;, which describes eight readings across tilapia, shrimp, and catfish ponds: critical dissolved oxygen on POND-A1, elevated ammonia on POND-B3, calibration overdue with high turbidity on POND-C2, low pH after rain on POND-A2, heat stress with biomass overload on POND-D1, feeding window conflict on POND-B1, a normal routine check on POND-E1, and ammonia spike with overstocking on POND-A3.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/validate/sample&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;BatchValidationReport&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_sample&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;BatchValidationReport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;readings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sample_readings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;run_batch_validation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;readings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wired CORS permissively because this is a local PoC dashboard, not a public deployment. If I hardened the experiment, authentication and farm tenancy would come first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Batch aggregation logic
&lt;/h3&gt;

&lt;p&gt;After the graph finishes, &lt;code&gt;run_batch_validation&lt;/code&gt; aggregates urgency counts and anomaly histograms. This function is plain Python on purpose; not every step needs to be a graph node. I kept summarization outside the loop so unit tests can call &lt;code&gt;validate_reading&lt;/code&gt; directly without invoking LangGraph when I want faster feedback.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;BatchValidationReport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;farm_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;farm_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;processed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;emergency_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;UrgencyLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EMERGENCY&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;high_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;UrgencyLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HIGH&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;summary_stats&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;avg_confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anomaly_distribution&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;anomaly_distribution&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The summary statistics feed the dashboard cards and the Matplotlib chart in the README. When I first omitted &lt;code&gt;anomaly_distribution&lt;/code&gt;, the UI felt empty even though per-row validation was correct. Aggregates matter for human scanability.&lt;/p&gt;

&lt;h3&gt;
  
  
  CLI presentation layer
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;main.py&lt;/code&gt; uses Rich tables because field operators often work from tabular summaries. I print farm name, urgency counts, and per-reading rows with color-coded urgency. The CLI became the source of truth for the GIF animation frames, which keeps marketing assets honest.&lt;/p&gt;

&lt;h3&gt;
  
  
  What surprised me during implementation
&lt;/h3&gt;

&lt;p&gt;When I first ran the batch, a shrimp reading with borderline dissolved oxygen and elevated ammonia scored high urgency but missed feeding conflict until I added note keyword detection. I tightened feed phrase matching and prioritized ammonia patterns for shrimp. Another surprise: confidence scoring felt too flat when every result returned the same value. I added small boosts when multiple anomalies co-occur and when calibration is fresh, which better reflected my own certainty when testing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Urgency mapping logic
&lt;/h3&gt;

&lt;p&gt;The urgency agent applies a priority ladder I sketched on paper before coding. Critical dissolved oxygen always wins emergency status. Feeding window conflicts escalate to high even when chemistry is borderline, because distributing feed into poor water quality accelerates ammonia production. Isolated calibration overdue stays low unless paired with chemistry anomalies. I wrote the ladder as explicit conditionals rather than a scored formula because explainability beat marginal accuracy in this PoC.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;urgency_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;anomalies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ReadingAnomaly&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;UrgencyLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ReadingAnomaly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DISSOLVED_OXYGEN_CRITICAL&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;anomalies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;UrgencyLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EMERGENCY&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ReadingAnomaly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FEEDING_WINDOW_CONFLICT&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;anomalies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;UrgencyLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HIGH&lt;/span&gt;
    &lt;span class="c1"&gt;# ... additional branches for low DO, ammonia, biomass, pH, temperature
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Readers forking the repo should treat this function as the first customization point. Every farm operates with different risk appetite, and swapping urgency tables is easier than rewriting an LLM prompt.&lt;/p&gt;

&lt;h3&gt;
  
  
  Confidence scoring rationale
&lt;/h3&gt;

&lt;p&gt;Confidence is not model probability here; it is a heuristic transparency score. Base confidence starts at 0.72. Multiple anomalies add 0.08. High or emergency urgency adds 0.07. Fresh calibration within thirty days adds 0.05. The cap is 0.96 because I never want a deterministic rules engine pretending certainty it cannot justify. When I present confidence in the CLI, I intend it as a sort key for human review, not as a statistical guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 4 — Operations, Reflections, and Next Steps
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Let's Setup
&lt;/h2&gt;

&lt;p&gt;Step-by-step details can be found at the repository README: &lt;a href="https://github.com/aniket-work/AquaGuard-AI" rel="noopener noreferrer"&gt;https://github.com/aniket-work/AquaGuard-AI&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Locally, the setup path I used looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Clone the repository and create a virtual environment inside the project root.&lt;/li&gt;
&lt;li&gt;Install dependencies from &lt;code&gt;requirements.txt&lt;/code&gt;, which pins LangGraph, FastAPI, Rich, and Matplotlib among others.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;python main.py&lt;/code&gt; to validate the sample JSON batch and print the Rich summary table.&lt;/li&gt;
&lt;li&gt;Optional: run &lt;code&gt;python main.py --chart&lt;/code&gt; to generate &lt;code&gt;images/validation-stats-chart.png&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Optional: start &lt;code&gt;uvicorn aquaguard.api.server:app --app-dir src --reload&lt;/code&gt; and open the dashboard at port 8000.&lt;/li&gt;
&lt;li&gt;Optional: execute &lt;code&gt;pytest tests/ -v&lt;/code&gt; to confirm emergency dissolved oxygen detection and feeding conflict presence.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I recommend keeping the virtual environment inside the repository folder for PoCs like this so paths to &lt;code&gt;data/sample_pond_readings.json&lt;/code&gt; remain predictable when you run scripts from different working directories.&lt;/p&gt;

&lt;p&gt;Environment variables are not required for the default demo. If you extend the project with embedding-based retrieval or an external LLM provider, add keys through a &lt;code&gt;.env&lt;/code&gt; file but do not commit secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Run
&lt;/h2&gt;

&lt;p&gt;Running &lt;code&gt;python main.py&lt;/code&gt; against the bundled dataset typically reports eight processed readings with two emergency and three high urgency findings in my latest run. Emergency items included POND-A1 critical dissolved oxygen at 2.4 mg/L and POND-B1 feeding window conflict combined with sub-threshold dissolved oxygen for shrimp. Average confidence landed around eighty-six percent with an average response window near twelve hours across the mixed batch.&lt;/p&gt;

&lt;p&gt;The CLI output ends with an ASCII-friendly table that mirrors what I animated in the repository GIF. That was intentional: I wanted the marketing asset and the actual tool output to tell the same story.&lt;/p&gt;

&lt;p&gt;For API mode, POST &lt;code&gt;/api/validate/sample&lt;/code&gt; returns JSON containing per-reading &lt;code&gt;recommended_actions&lt;/code&gt; and &lt;code&gt;policy_refs&lt;/code&gt;. The dashboard renders urgency cards and a results table without a separate frontend build step.&lt;/p&gt;

&lt;p&gt;To generate analytics assets, run &lt;code&gt;python main.py --chart&lt;/code&gt;. It writes &lt;code&gt;images/validation-stats-chart.png&lt;/code&gt;, which I included in the README so GitHub visitors see quantitative behavior immediately.&lt;/p&gt;

&lt;p&gt;When I demo this PoC to myself after a break, I use a three-step smoke path: CLI batch, pytest, then dashboard POST. That sequence exercises the graph, validation rules, and HTTP layer without needing external services. If all three pass, I trust the repository state enough to publish diagrams or write about it.&lt;/p&gt;

&lt;p&gt;For readers who prefer API exploration with curl, &lt;code&gt;curl -X POST http://localhost:8000/api/validate/sample&lt;/code&gt; returns the full &lt;code&gt;BatchValidationReport&lt;/code&gt; JSON. I often pipe that output through &lt;code&gt;jq&lt;/code&gt; to inspect a single reading's &lt;code&gt;recommended_actions&lt;/code&gt; array and verify policy references attached correctly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Species threshold tables I encoded
&lt;/h3&gt;

&lt;p&gt;The business rules in this PoC are intentionally visible as Python dictionaries rather than buried in prompts. Tilapia dissolved oxygen minimum is 4.0 mg/L with a critical floor at 3.0 mg/L. Shrimp minimum is 5.0 mg/L with critical at 4.0 mg/L because shrimp mortality risk rises faster at marginal oxygen. Catfish tolerates lower oxygen but still flags critical below 2.5 mg/L. Ammonia ceilings differ similarly: shrimp at 0.25 mg/L, tilapia at 0.5 mg/L, catfish at 0.6 mg/L. I chose these numbers from publicly available aquaculture extension ranges, then simplified them for demo clarity. They are not farm-specific standards.&lt;/p&gt;

&lt;p&gt;Temperature bands matter because heat increases metabolic oxygen demand. When POND-D1 reports 33.5 Celsius for shrimp with biomass at 16.5 kg/m3, two agents fire: temperature stress and biomass overload. That combination is exactly the kind of multi-signal case I wanted LangGraph to handle without one agent silently overriding another.&lt;/p&gt;

&lt;h3&gt;
  
  
  Human-in-the-loop considerations
&lt;/h3&gt;

&lt;p&gt;Even in a deterministic PoC, I reserved mental space for human review. Emergency readings should page someone, not close the loop autonomously. LangGraph supports &lt;code&gt;interrupt_before&lt;/code&gt; on nodes, and I would place that interrupt before any future "execute intervention" node if I connected to real actuators. Today the action agent only returns text recommendations. That boundary matters ethically and practically.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6km2k7qfuzkakfkzlbeu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6km2k7qfuzkakfkzlbeu.png" alt="Stats Chart" width="800" height="346"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge Cases and Testing Philosophy
&lt;/h2&gt;

&lt;p&gt;I wrote four pytest cases covering batch length, emergency dissolved oxygen detection, feeding window conflicts, and non-empty remediation steps on actionable results. Tests encode what I care about in this PoC: critical dissolved oxygen must never be downgraded quietly, and every non-normal result must include recommended actions.&lt;/p&gt;

&lt;p&gt;Ambiguous operator notes remain a weakness. A note mentioning "feed" in a unrelated context might still trigger feeding logic if phrasing overlaps keywords. In production I would route low-confidence classifications to a human queue. Here, I expose &lt;code&gt;agent_trace&lt;/code&gt; lists to make that queue possible later.&lt;/p&gt;

&lt;p&gt;Species tables only cover three species in this demo. A real farm with hybrid stocking or polyculture would need composite rules. That limitation is acceptable in a demo but worth documenting honestly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Walking Through a Sample Reading
&lt;/h2&gt;

&lt;p&gt;To make the pipeline concrete, consider reading &lt;code&gt;R-2026-0629-001&lt;/code&gt; from the sample dataset: tilapia pond POND-A1 reports dissolved oxygen at 2.4 mg/L with a feed batch mentioned in operator notes. When I feed this batch through the graph, the sensor agent flags critical dissolved oxygen below the tilapia floor. The species agent confirms chemistry is otherwise within band. The feeding agent detects a feed-window conflict because notes mention feeding while dissolved oxygen is sub-threshold. The compliance agent retrieves &lt;code&gt;AQ-TIL-001: Tilapia Dissolved Oxygen Minimum&lt;/code&gt;. The action agent responds with emergency aeration, feed suspension, and technician dispatch with a one-hour response window.&lt;/p&gt;

&lt;p&gt;That end-to-end path takes milliseconds in my local runs, but the value is not speed. The value is repeatability. Every reading in the batch gets the same structured treatment, which means I can diff reports across code versions when I tune heuristics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Policy Knowledge Base Design
&lt;/h2&gt;

&lt;p&gt;I modeled the knowledge layer as a list of &lt;code&gt;PolicyChunk&lt;/code&gt; dataclass rows rather than jumping straight to ChromaDB. Each chunk stores a code, title, body, species tag, and category. The search function tokenizes query and body text, counts overlap, and applies species and category boosts. This is intentionally primitive. From my experience building RAG demos, people sometimes spend days on embedding pipelines before validating whether retrieval inputs are structured correctly. Here, the interface is stable: &lt;code&gt;search_policies(query, species, category, top_k)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If I swap in embeddings later, agents above the knowledge layer stay unchanged. That separation mattered to me because it mirrors how I would approach a production migration: nail schemas and agent IO first, upgrade retrieval second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monolithic Prompt Versus Specialist Agents
&lt;/h2&gt;

&lt;p&gt;Early in the experiment I tried a single prompt that asked for anomalies, urgency, citations, and actions in one JSON blob. It failed in boring ways: urgency would be conservative when citations were verbose, and shrimp ammonia violations were mislabeled as temperature stress when heat notes appeared in the same sentence. Splitting responsibilities into agents eliminated most of that cross-talk. Each agent receives only the fields it needs, writes to &lt;code&gt;agent_trace&lt;/code&gt;, and returns a typed value the coordinator merges.&lt;/p&gt;

&lt;p&gt;This aligns with how I think about LangGraph more generally. Nodes are functions with narrow contracts. Edges express control flow a field operator could whiteboard. When I revisit the monolithic prompt idea, it will be as a final summarization layer, not as the core reasoning engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dashboard and Visualization Choices
&lt;/h2&gt;

&lt;p&gt;The HTML dashboard uses a dark theme with urgency-colored badges because farm monitoring tools I have seen in demos often default to sterile white tables that hide urgency. I wanted emergency items to visually pop. The Matplotlib chart uses horizontal bars for urgency and top anomaly categories. Static charts export cleanly to README, which helps GitHub visitors who never run the code still grasp batch behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance and Operational Notes
&lt;/h2&gt;

&lt;p&gt;The sample batch of eight readings completes in under a second on my laptop, including graph orchestration overhead. I did not optimize for throughput because validation batches in this PoC are tiny. If batches grew to thousands of historical readings, I would parallelize per-reading validation outside LangGraph or shard by farm while keeping the same agent functions.&lt;/p&gt;

&lt;p&gt;Memory footprint stays small because readings are pydantic objects in a list, not streamed from an IoT broker. Logging is print-based in the CLI and trace lists in results. A next step would be structured JSON logs with reading IDs for observability, but I skipped that to keep the repository approachable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Theory Behind Response Hours
&lt;/h2&gt;

&lt;p&gt;Response hours encode how quickly I believe an operator must show correction before stock health degrades. Critical dissolved oxygen and feeding conflicts map to one or four hours in my heuristics because shrimp and tilapia cannot wait a business week at sub-threshold oxygen. Sensor calibration overdue maps to forty-eight hours because it is serious but not always an immediate mortality event. These numbers are not organizational standards; they are PoC placeholders where I tried to mirror operational urgency I observed in aquaculture extension materials.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integration Points I Deliberately Left Open
&lt;/h2&gt;

&lt;p&gt;The FastAPI layer returns pydantic-serialized JSON ready for webhooks, SMS workers, or farm management dashboards I did not build. &lt;code&gt;agent_trace&lt;/code&gt; arrays are the extension point for human review queues: a UI could highlight low-confidence rows or readings where sensor and species agents disagreed with a prior human label. The compliance agent returns string citations today; tomorrow it could return structured objects with URLs to regulatory documents.&lt;/p&gt;

&lt;p&gt;I also left authentication out entirely. If this ever faced real farm credentials or tenant data, isolation and role-based access would precede any model upgrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ethics and Boundaries
&lt;/h2&gt;

&lt;p&gt;Automating pond health validation touches live animal welfare and economic outcomes for farm operators. I want to be explicit: this PoC does not activate aerators, does not suspend feed systems, and does not connect to live IoT control planes. It sorts readings you give it. If someone deployed a derivative system, human review on emergency outcomes would be non-negotiable in my view, and rule changes would require traceable versioning.&lt;/p&gt;

&lt;p&gt;I also avoided implying employer affiliation. This build lives entirely in my personal GitHub namespace as an experiment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Future Roadmap I Would Explore
&lt;/h2&gt;

&lt;p&gt;If I continue this PoC, my next steps would be:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Replace lexical policy search with embeddings stored in Chroma or pgvector, then measure citation precision against a held-out reading set.&lt;/li&gt;
&lt;li&gt;Add an LLM summarization step that rewrites recommended actions into field runbook language while keeping structured JSON underneath.&lt;/li&gt;
&lt;li&gt;Introduce pond history so repeat dissolved oxygen dips escalate urgency automatically.&lt;/li&gt;
&lt;li&gt;Export intervention ticket drafts with policy footnotes for farm management systems.&lt;/li&gt;
&lt;li&gt;Build a feedback endpoint where a human reviewer marks agent mistakes, creating training data for later fine-tuning.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of those are implemented here. They are the natural extension points I noted while writing the graph skeleton.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I would not change
&lt;/h3&gt;

&lt;p&gt;If I rebuilt the PoC from scratch, I would keep three decisions exactly as they are: strict pydantic schemas at the boundary, separate specialist modules instead of one prompt file, and &lt;code&gt;agent_trace&lt;/code&gt; on every result. Those choices cost almost nothing in lines of code but paid off every time I debugged a surprising urgency label.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reader exercises
&lt;/h3&gt;

&lt;p&gt;If you fork the repository, here are three exercises I found useful while learning:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add a new reading to &lt;code&gt;sample_pond_readings.json&lt;/code&gt; with ambiguous operator notes and write a test that asserts your expected urgency.&lt;/li&gt;
&lt;li&gt;Introduce a sixth agent that drafts a plain-language summary paragraph per reading without altering structured fields.&lt;/li&gt;
&lt;li&gt;Swap lexical policy search for an embedding model and compare citation precision on ten hand-labeled examples.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each exercise touches a different production concern: evaluation, human-readable output, and retrieval quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparing Batch Outputs Across Iterations
&lt;/h2&gt;

&lt;p&gt;One habit I kept from prior agent experiments is saving JSON reports when iterating heuristics. AquaGuard-AI supports &lt;code&gt;--json-out&lt;/code&gt; on the CLI. Diffing two JSON files after a species threshold tweak shows exactly which reading IDs changed urgency or response hours. That practice sounds tedious, but it prevented regressions when I adjusted shrimp ammonia ceilings. Without structured output, I would rely on eyeballing terminal tables, which does not scale past a handful of readings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Aquaculture and Not Another Domain
&lt;/h2&gt;

&lt;p&gt;I rotate domains in my personal agent experiments to avoid repeating myself. Aquaculture sat outside my recent project history and offered a rich rule surface: species biology, equipment trust, feeding economics, and regulatory policy all intersect in one reading. That intersection is what makes multi-agent validation interesting. A single monolithic classifier would need to simultaneously reason about chemistry, biology, and operations. Splitting agents let me test each concern independently and combine results with traceable provenance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;AquaGuard-AI started as a question about aquaculture triage labor, not as a pitch for autonomous farm management. What I ended up with is a compact LangGraph workflow that feels honest about its limits: deterministic agents, explicit traces, readable tests, and an API that could swap underneath without rewriting business logic.&lt;/p&gt;

&lt;p&gt;From my experience, the hardest part of multi-agent tutorials is not drawing boxes on a whiteboard. It is choosing a domain where evaluation is grounded. Pond sensor readings gave me that grounding. When the urgency agent mistyped a shrimp ammonia issue, tests failed or traces looked wrong immediately. When species heuristics worked, the CLI table matched my own gut ranking.&lt;/p&gt;

&lt;p&gt;If you are building your own orchestration experiments, steal the structure more than the rules. Keep schemas strict, keep agents narrow, keep orchestration visible, and keep humans in the loop for emergency outcomes. The repository at &lt;a href="https://github.com/aniket-work/AquaGuard-AI" rel="noopener noreferrer"&gt;https://github.com/aniket-work/AquaGuard-AI&lt;/a&gt; is there if you want to run the same path I did and remix it for a completely different operational domain.&lt;/p&gt;

&lt;p&gt;One last reflection: agent hype often focuses on autonomy. This project convinced me that disciplined validation is the more interesting problem. Pond readings do not need a chatty assistant; they need reliable sorting, traceable reasoning, and fast handoff to humans when stakes are high. LangGraph helped me express that validation story in code without pretending the PoC is more than an experiment.&lt;/p&gt;

&lt;p&gt;I will keep iterating on personal agent projects like this because each one teaches a reusable pattern. AquaGuard-AI's pattern is narrow agents, explicit graph state, honest tests, and dashboards that mirror terminal truth. That combination is what I would carry into the next domain, whatever it ends up being.&lt;/p&gt;

&lt;p&gt;Cover animation for readers who prefer visual summaries:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxnk5gimy0vaabnxglyvt.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxnk5gimy0vaabnxglyvt.gif" alt="Cover Animation" width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclaimer
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Tags: python, langgraph, ai, aquaculture&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>langgraph</category>
      <category>ai</category>
      <category>aquaculture</category>
    </item>
    <item>
      <title>Building an Intelligent Schema Drift Router for Data Pipelines with LangGraph</title>
      <dc:creator>Aniket Hingane</dc:creator>
      <pubDate>Mon, 22 Jun 2026 02:32:40 +0000</pubDate>
      <link>https://dev.to/exploredataaiml/building-an-intelligent-food-safety-inspection-router-with-langgraph-2288</link>
      <guid>https://dev.to/exploredataaiml/building-an-intelligent-food-safety-inspection-router-with-langgraph-2288</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1e4jqx8029m9hwb5f71s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1e4jqx8029m9hwb5f71s.png" alt=" " width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How I Automated Data Contract Violation Triage Using Multi-Agent Orchestration&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I built SchemaFlow-AI as a personal proof-of-concept to explore a problem I kept running into while experimenting with lakehouse pipelines: schema drift alerts are easy to generate, but the hard part is deciding what each alert means, which data contract clause applies, and how urgently someone should act before downstream marts break. This experimental project routes schema drift events through a LangGraph-orchestrated multi-agent pipeline backed by FastAPI and a lightweight data contract knowledge index. Each event passes through classifier, severity, contract retrieval, and remediation agents before landing in a structured triage report with SLA hours and agent traces. The full source code lives at &lt;a href="https://github.com/aniket-work/SchemaFlow-AI" rel="noopener noreferrer"&gt;https://github.com/aniket-work/SchemaFlow-AI&lt;/a&gt;. In my opinion, the most interesting takeaway is not any single agent prompt, but how graph-based orchestration keeps specialist steps composable without turning the codebase into spaghetti. This write-up documents my design choices, the code I wrote along the way, and what I would change if I extended the PoC further.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;A few months ago I started paying closer attention to what happens after a bronze-to-silver job fails at eight in the morning. From my experience reading incident threads and anonymized pipeline logs, the error message itself is rarely the whole story. A dbt model might complain about a schema mismatch because &lt;code&gt;discount_code&lt;/code&gt; disappeared upstream, but the on-call engineer still has to answer harder questions. Is this a column removal or a type change? Does it block revenue dashboards or only a staging table? Which data contract governs the fix, and how many hours do I have before SLA breach?&lt;/p&gt;

&lt;p&gt;I wanted to test whether multi-agent orchestration could compress that downstream triage work without pretending to replace human judgment. SchemaFlow-AI is the result of that experiment. It is not production software, not something I deployed at any employer, and not a claim about how data platforms should operate. It is a solo PoC I built to learn LangGraph patterns in a domain that felt concrete and underserved in agent tutorials.&lt;/p&gt;

&lt;p&gt;The architecture rhymes with other automation experiments I have tried: specialist agents, a coordinator, a knowledge layer for retrieval, and a thin API for integration. I deliberately chose schema drift and data contract triage rather than chatbots or document summarization because those demos are overrepresented, and because pipeline incidents have crisp inputs and outputs that make evaluation honest.&lt;/p&gt;

&lt;p&gt;What pulled me toward this domain specifically was the asymmetry between detection and response. Modern stacks already emit Great Expectations failures, dbt test errors, and Airflow alerts. The gap I noticed was structured triage: turning noisy drift signals into category labels, severity scores, contract citations, and remediation steps that an on-call engineer could accept or override quickly. I wondered whether explicit agent roles could mirror that mental sorting without hiding reasoning inside a black-box model.&lt;/p&gt;

&lt;p&gt;I also wanted a use case where mistakes are obvious. If an agent labels a missing daily partition as advisory, tests should scream. That clarity helped me iterate faster than I would have in a vague business insights demo where quality is subjective.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's This Article About?
&lt;/h2&gt;

&lt;p&gt;This article walks through how I designed and implemented SchemaFlow-AI, a multi-agent system that ingests batches of schema drift events and emits triage reports containing drift category labels, impact severity scores, data contract references, recommended remediation steps, and SLA hour estimates. You will see the LangGraph state machine I used, the specialist agents I wrote, the contract knowledge module that acts as a stand-in for retrieval-augmented generation, and the FastAPI surface that exposes the workflow to a simple dashboard.&lt;/p&gt;

&lt;p&gt;I also cover setup and execution steps so you can reproduce the PoC locally, and I share edge cases I discovered while testing, including ambiguous event descriptions that fooled the classifier and severity heuristics that needed tuning. Throughout, I frame conclusions as personal observations from an experimental build rather than authoritative guidance for regulated pipeline environments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;p&gt;I kept the stack intentionally small. Complexity in agent systems often hides in dependencies long before it hides in prompts, and I wanted to feel every layer.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Python 3.11&lt;/strong&gt; as the runtime. Strong typing with Pydantic models made drift event batches easy to validate at API boundaries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LangGraph&lt;/strong&gt; for orchestration. I considered a hand-rolled coordinator loop, but LangGraph gave me explicit state transitions and a graph I could diagram without lying.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FastAPI&lt;/strong&gt; for HTTP endpoints and to serve a minimal HTML dashboard from the same process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pydantic v2&lt;/strong&gt; for schemas such as &lt;code&gt;DriftEvent&lt;/code&gt;, &lt;code&gt;TriageResult&lt;/code&gt;, and &lt;code&gt;BatchTriageReport&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rich&lt;/strong&gt; for CLI tables. Terminal output matters in data engineering workflows because many pipeline tools still live partly in terminals and JSON exports.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Matplotlib&lt;/strong&gt; for severity and category distribution charts. I wanted at least one visual summary of learned triage statistics, not because the charts are fancy, but because they make batch behavior obvious at a glance.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I skipped a full vector database in this PoC. A lexical contract index was enough to prove the RAG insertion point, and it kept the repository approachable for readers who want to clone and run in ten minutes.&lt;/p&gt;

&lt;p&gt;As per my experience shipping small services, I optimize for clone-to-green time in PoCs. Every extra infrastructure component is a reader who bounces at step four of the README. LangGraph plus FastAPI plus a JSON fixture gets people to a colored terminal table quickly. That table is the emotional payoff that convinces someone to read the rest of the design sections.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Role in SchemaFlow-AI&lt;/th&gt;
&lt;th&gt;Why I chose it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LangGraph&lt;/td&gt;
&lt;td&gt;Per-event drift loop&lt;/td&gt;
&lt;td&gt;Makes orchestration visible and testable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FastAPI&lt;/td&gt;
&lt;td&gt;HTTP + static dashboard&lt;/td&gt;
&lt;td&gt;Single process dev experience&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pydantic&lt;/td&gt;
&lt;td&gt;Schema guardrails&lt;/td&gt;
&lt;td&gt;Prevents malformed event batches early&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rich&lt;/td&gt;
&lt;td&gt;CLI tables&lt;/td&gt;
&lt;td&gt;Matches on-call friendly output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Matplotlib&lt;/td&gt;
&lt;td&gt;Drift category charts&lt;/td&gt;
&lt;td&gt;Quant summary for README and article&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjn5mkay8qlpczir91vd8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjn5mkay8qlpczir91vd8.png" alt="Architecture" width="800" height="246"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Read It?
&lt;/h2&gt;

&lt;p&gt;If you are experimenting with LangGraph and tired of toy chatbots, schema drift triage offers a disciplined sandbox. Inputs are structured pipeline incident descriptions. Outputs are enums, contract codes, and remediation lists. That makes it easy to tell when an agent misbehaved without hand-waving about subjective quality.&lt;/p&gt;

&lt;p&gt;From my perspective, three ideas in this PoC transfer to other domains:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Specialist agents beat monolithic prompts&lt;/strong&gt; when each step has different failure modes. Severity scoring and contract lookup should not share one prompt context window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graph orchestration documents operational process&lt;/strong&gt; better than nested if-statements. Data engineers already think in workflows; LangGraph mirrors that honestly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A thin API unlocks multiple clients&lt;/strong&gt; with one workflow. I used both a Rich CLI and a browser dashboard against the same triage function.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The GitHub repository at &lt;a href="https://github.com/aniket-work/SchemaFlow-AI" rel="noopener noreferrer"&gt;https://github.com/aniket-work/SchemaFlow-AI&lt;/a&gt; includes tests, sample data, diagrams, and a title animation GIF generated from terminal output plus a dashboard preview. Clone it, run &lt;code&gt;python main.py&lt;/code&gt;, and you will see the same ASCII table aesthetic I embedded in the article cover animation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Design
&lt;/h2&gt;

&lt;p&gt;Before writing code I sketched the lifecycle of a single drift event. An alert fires because bronze landed with unexpected shape, a partition is missing, or freshness slipped. A triage system must answer four questions: what kind of drift is this, how severely does it impact consumers, which data contract clauses apply, and what remediation path unblocks the pipeline fastest. Those questions map cleanly to four agents plus a coordinator.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6su5e5m8eghzzjm7e5nd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6su5e5m8eghzzjm7e5nd.png" alt="Sequence" width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Coordinator and state
&lt;/h3&gt;

&lt;p&gt;The coordinator owns batch progress. LangGraph state carries the event list, accumulated results, pipeline name, and a numeric index. I chose explicit index-based iteration rather than recursive graph tricks because it made debugging easier when a single event produced surprising output.&lt;/p&gt;

&lt;h3&gt;
  
  
  Classifier agent
&lt;/h3&gt;

&lt;p&gt;The classifier maps free text into a &lt;code&gt;DriftCategory&lt;/code&gt; enum: column added, column removed, type mismatch, null spike, partition gap, freshness breach, row count anomaly, or constraint violation. I used keyword scoring rather than an LLM because I wanted deterministic tests. In a later iteration I would hybridize: rules for high-confidence patterns, LLM fallback for ambiguous notes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Severity agent
&lt;/h3&gt;

&lt;p&gt;Severity is where pipeline impact actually lives. A missing optional column is not the same as a removed merge key or a freshness SLA breach on a revenue-facing mart. I encoded heuristics such as detecting column removal, eighteen percent null spikes on &lt;code&gt;customer_id&lt;/code&gt;, or twenty-six hour freshness gaps against a six hour SLA. The agent emits &lt;code&gt;critical&lt;/code&gt;, &lt;code&gt;major&lt;/code&gt;, &lt;code&gt;minor&lt;/code&gt;, or &lt;code&gt;advisory&lt;/code&gt;. This is the step I would be most cautious about deploying without human review, which is why the PoC prints confidence scores and agent traces.&lt;/p&gt;

&lt;h3&gt;
  
  
  Contract agent
&lt;/h3&gt;

&lt;p&gt;This agent queries a small data contract chunk index. Each chunk includes a code like &lt;code&gt;DC-ORD-001&lt;/code&gt;, title, body, and category tag. Search is lexical overlap with a category boost. It is a deliberate stand-in for embedding retrieval, but the interface is stable: pass description and category, receive citation strings.&lt;/p&gt;

&lt;h3&gt;
  
  
  Remediation agent
&lt;/h3&gt;

&lt;p&gt;Finally, the remediation agent converts category and severity into recommended corrective steps and an SLA hour count. Column removal suggests pausing the silver merge and notifying the producer owner within four hours. Freshness breaches trigger paging the pipeline owner within two hours. The remediation agent is where I encoded operational knowledge that on-call engineers already apply intuitively.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy4t32rv66wq5tajhujy8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy4t32rv66wq5tajhujy8.png" alt="Workflow" width="580" height="1160"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  API and clients
&lt;/h3&gt;

&lt;p&gt;FastAPI exposes &lt;code&gt;/api/triage/sample&lt;/code&gt; for the bundled dataset and &lt;code&gt;/api/triage&lt;/code&gt; for custom payloads. The root route serves a dark-themed dashboard that renders severity cards and a category bar chart after a POST call. I put the frontend in a single HTML file to avoid Node build tooling in a PoC README.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Get Cooking
&lt;/h2&gt;

&lt;p&gt;Here is where I translate the design into code. I split the implementation into schema models, specialist agents, the LangGraph workflow, and the API layer. I wrote each block to be readable in isolation because, in my experience, agent repos rot quickly when everything lives in one thousand-line module.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data models
&lt;/h3&gt;

&lt;p&gt;I started with Pydantic models because drift events arrive from JSON exports and alerting webhooks in real life, and I wanted validation before any agent touched the data. Strong typing also made FastAPI response models trivial to declare.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ImpactSeverity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;CRITICAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;critical&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;MAJOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;major&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;MINOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;minor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;ADVISORY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;advisory&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DriftCategory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;COLUMN_ADDED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;column_added&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;COLUMN_REMOVED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;column_removed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;TYPE_MISMATCH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type_mismatch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;NULL_SPIKE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;null_spike&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;PARTITION_GAP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;partition_gap&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;FRESHNESS_BREACH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;freshness_breach&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;ROW_COUNT_ANOMALY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;row_count_anomaly&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;CONSTRAINT_VIOLATION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;constraint_violation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DriftEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;detected_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;operator_notes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TriageResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DriftCategory&lt;/span&gt;
    &lt;span class="n"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ImpactSeverity&lt;/span&gt;
    &lt;span class="n"&gt;contract_refs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;remediation_steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sla_hours&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;agent_trace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These models became the contract between CLI, API, and agents. Keeping enums strict prevented silent string drift in severity labels when I generated charts later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Specialist agents
&lt;/h3&gt;

&lt;p&gt;Each agent appends to an &lt;code&gt;agent_trace&lt;/code&gt; list so I could explain decisions in the CLI and in future audit logs. The classifier scores keyword hits per category:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classify_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DriftEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DriftCategory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;operator_notes&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;best_category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DriftCategory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TYPE_MISMATCH&lt;/span&gt;
    &lt;span class="n"&gt;best_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keywords&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;CATEGORY_KEYWORDS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;kw&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;keywords&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kw&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;best_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;best_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;
            &lt;span class="n"&gt;best_category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;
    &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ClassifierAgent: mapped to &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;best_category&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (score=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;best_score&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;best_category&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Severity logic encodes the pipeline intuition I mentioned earlier. For example, upstream column removal becomes critical, while an unapproved new column stays minor. I put it this way because severity mistakes are costlier than category mistakes: they directly influence paging urgency and SLA clocks.&lt;/p&gt;

&lt;p&gt;The contract agent wraps the knowledge search:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;contract_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DriftCategory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DriftEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;search_contracts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;refs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ContractAgent: retrieved &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;refs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; contract references&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;refs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The remediation agent returns both steps and SLA hours. That pairing is what makes the output feel operational instead of academic.&lt;/p&gt;

&lt;h3&gt;
  
  
  LangGraph workflow
&lt;/h3&gt;

&lt;p&gt;The graph is small but worth showing because it is the spine of the PoC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_triage_graph&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StateGraph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TriageState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;load&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_load_batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;process&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_process_next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summarize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_summarize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_entry_point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;load&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;load&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;process&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_conditional_edges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;process&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;_should_continue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;process&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;process&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summarize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summarize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summarize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;END&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each pass through &lt;code&gt;process&lt;/code&gt; triages one drift event and increments the index. When the index reaches the batch length, the graph routes to &lt;code&gt;summarize&lt;/code&gt;, which is a hook I left open for aggregate analytics such as average confidence or category histograms.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;run_batch_triage&lt;/code&gt; computes severity counts and category distributions after the graph finishes. That summary feeds both the Rich CLI table and the Matplotlib chart in &lt;code&gt;src/analytics.py&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  FastAPI surface
&lt;/h3&gt;

&lt;p&gt;The API is intentionally thin. It validates payloads, calls &lt;code&gt;run_batch_triage&lt;/code&gt;, and returns a &lt;code&gt;BatchTriageReport&lt;/code&gt;. The sample endpoint loads &lt;code&gt;data/sample_drift_events.json&lt;/code&gt;, which describes eight findings on the &lt;code&gt;orders-bronze-to-silver&lt;/code&gt; pipeline: a removed &lt;code&gt;discount_code&lt;/code&gt; column, an unapproved &lt;code&gt;promo_tier&lt;/code&gt; column, an eighteen point four percent null spike on &lt;code&gt;customer_id&lt;/code&gt;, a missing daily partition, a &lt;code&gt;DOUBLE&lt;/code&gt; versus &lt;code&gt;DECIMAL&lt;/code&gt; type change on &lt;code&gt;order_total&lt;/code&gt;, a twenty-six hour freshness breach, a sixty-two percent row count drop, and a new &lt;code&gt;order_status&lt;/code&gt; enum value violating constraints.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/triage/sample&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;BatchTriageReport&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;triage_sample&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;BatchTriageReport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sample_events&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;run_batch_triage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wired CORS permissively because this is a local PoC dashboard, not a public deployment. If I hardened the experiment, authentication and pipeline tenancy would come first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Batch aggregation logic
&lt;/h3&gt;

&lt;p&gt;After the graph finishes, &lt;code&gt;run_batch_triage&lt;/code&gt; aggregates severity counts and category histograms. This function is plain Python on purpose; not every step needs to be a graph node. I kept summarization outside the loop so unit tests can call &lt;code&gt;triage_event&lt;/code&gt; directly without invoking LangGraph when I want faster feedback.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_batch_triage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DriftEvent&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;BatchTriageReport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;graph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_triage_graph&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;final&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pipeline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;TriageResult&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;final&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;counts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ImpactSeverity&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;category_counts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;category_counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;category_counts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;BatchTriageReport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;processed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;critical_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ImpactSeverity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CRITICAL&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;major_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ImpactSeverity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MAJOR&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;minor_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ImpactSeverity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MINOR&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;advisory_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ImpactSeverity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ADVISORY&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;summary_stats&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;avg_confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;avg_sla_hours&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sla_hours&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category_distribution&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;category_counts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The summary statistics feed the dashboard bar chart and the README analytics image. When I first omitted &lt;code&gt;category_distribution&lt;/code&gt;, the UI felt empty even though per-row triage was correct. Aggregates matter for human scanability.&lt;/p&gt;

&lt;h3&gt;
  
  
  CLI presentation layer
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;main.py&lt;/code&gt; uses Rich tables because on-call engineers often work from tabular summaries. I print pipeline name, severity counts, and per-event rows with color-coded severity. The CLI became the source of truth for the GIF animation frames, which keeps marketing assets honest.&lt;/p&gt;

&lt;h3&gt;
  
  
  What surprised me during implementation
&lt;/h3&gt;

&lt;p&gt;When I first ran the batch, partition-related keywords in a row count note caused a false partition classification. I tightened category keyword lists and prioritized null rate patterns. Another surprise: confidence scoring felt too flat when every result returned the same value. I added small boosts when contract hits exist and when severity is critical or major, which better reflected my own certainty when testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Setup
&lt;/h2&gt;

&lt;p&gt;Step-by-step details can be found at the repository README: &lt;a href="https://github.com/aniket-work/SchemaFlow-AI" rel="noopener noreferrer"&gt;https://github.com/aniket-work/SchemaFlow-AI&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Locally, the setup path I used looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Clone the repository and create a virtual environment inside the project root.&lt;/li&gt;
&lt;li&gt;Install dependencies from &lt;code&gt;requirements.txt&lt;/code&gt;, which pins LangGraph, FastAPI, Rich, and Matplotlib among others.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;python main.py&lt;/code&gt; to triage the sample JSON batch and print the Rich summary table.&lt;/li&gt;
&lt;li&gt;Optional: start &lt;code&gt;uvicorn schemaflow.api.server:app --app-dir src --reload&lt;/code&gt; and open the dashboard at port 8000.&lt;/li&gt;
&lt;li&gt;Optional: execute &lt;code&gt;pytest tests/ -v&lt;/code&gt; to confirm critical column removal events and remediation list presence.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I recommend keeping the virtual environment inside the repository folder for PoCs like this so paths to &lt;code&gt;data/sample_drift_events.json&lt;/code&gt; remain predictable when you run scripts from different working directories.&lt;/p&gt;

&lt;p&gt;Environment variables are not required for the default demo. If you extend the project with embedding-based retrieval or an external LLM provider, add keys through a &lt;code&gt;.env&lt;/code&gt; file but do not commit secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Run
&lt;/h2&gt;

&lt;p&gt;Running &lt;code&gt;python main.py&lt;/code&gt; against the bundled dataset typically reports eight processed events with three critical and four major findings in my latest run. Critical items included the removed &lt;code&gt;discount_code&lt;/code&gt; column blocking dbt merge, the &lt;code&gt;customer_id&lt;/code&gt; null spike, and the freshness SLA breach. Average confidence landed around eighty-seven percent with an average SLA window near eleven hours.&lt;/p&gt;

&lt;p&gt;The CLI output ends with an ASCII-friendly table that mirrors what I animated in the repository GIF. That was intentional: I wanted the marketing asset and the actual tool output to tell the same story.&lt;/p&gt;

&lt;p&gt;For API mode, POST &lt;code&gt;/api/triage/sample&lt;/code&gt; returns JSON containing per-event &lt;code&gt;remediation_steps&lt;/code&gt; and &lt;code&gt;contract_refs&lt;/code&gt;. The dashboard renders severity cards and a horizontal bar chart of category distribution without a separate frontend build step.&lt;/p&gt;

&lt;p&gt;To generate analytics assets, run &lt;code&gt;python src/analytics.py&lt;/code&gt;. It writes &lt;code&gt;images/triage-stats-chart.png&lt;/code&gt;, which I included in the README so GitHub visitors see quantitative behavior immediately.&lt;/p&gt;

&lt;p&gt;When I demo this PoC to myself after a break, I use a three-step smoke path: CLI batch, pytest, then dashboard POST. That sequence exercises the graph, validation rules, and HTTP layer without needing external services. If all three pass, I trust the repository state enough to publish diagrams or write about it.&lt;/p&gt;

&lt;p&gt;For readers who prefer API exploration with curl, &lt;code&gt;curl -X POST http://localhost:8000/api/triage/sample&lt;/code&gt; returns the full &lt;code&gt;BatchTriageReport&lt;/code&gt; JSON. I often pipe that output through &lt;code&gt;jq&lt;/code&gt; to inspect a single event's &lt;code&gt;remediation_steps&lt;/code&gt; array and verify contract references attached correctly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1iuzlp60p6vcpidm580i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1iuzlp60p6vcpidm580i.png" alt="Stats Chart" width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge Cases and Testing Philosophy
&lt;/h2&gt;

&lt;p&gt;I wrote four pytest cases covering batch length, minimum critical detections, non-empty remediation steps and contract references, and a dedicated column removal severity check. Tests encode what I care about in this PoC: removed merge keys must never be downgraded quietly, and every triage result must be actionable.&lt;/p&gt;

&lt;p&gt;Ambiguous notes remain a weakness. A type mismatch description that mentions partitions sometimes scores against partition keywords even when the root issue is numeric precision. In production I would route low-confidence classifications to a human queue. Here, I expose &lt;code&gt;agent_trace&lt;/code&gt; lists to make that queue possible later.&lt;/p&gt;

&lt;p&gt;Freshness parsing only searches for hour counts in operator notes. Timezone-aware SLA math would need a dedicated library. That limitation is acceptable in a demo but worth documenting honestly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Walking Through a Sample Event
&lt;/h2&gt;

&lt;p&gt;To make the pipeline concrete, consider event &lt;code&gt;E-2026-0621-001&lt;/code&gt; from the sample dataset: upstream producer removed &lt;code&gt;discount_code&lt;/code&gt; and the silver merge job failed with schema mismatch. When I feed this batch through the graph, the coordinator hands the item to the classifier, which scores column removal keywords highest and maps the finding to &lt;code&gt;column_removed&lt;/code&gt;. The severity agent recognizes removal as blocking merge and labels the item critical. The contract agent retrieves &lt;code&gt;DC-ORD-001: Orders Fact Column Stability&lt;/code&gt; because the description mentions removal and the category boost aligns. The remediation agent responds with pausing the silver merge, notifying the producer owner, and backfilling after schema alignment with a four-hour SLA.&lt;/p&gt;

&lt;p&gt;That end-to-end path takes milliseconds in my local runs, but the value is not speed. The value is repeatability. Every event in the batch gets the same structured treatment, which means I can diff reports across code versions when I tune heuristics. When I adjusted keyword weights after a false positive on a row count note, I re-ran pytest and the CLI table immediately showed whether critical counts changed. That feedback loop is what I look for in agent PoCs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data Contract Knowledge Base Design
&lt;/h2&gt;

&lt;p&gt;I modeled the knowledge layer as a list of &lt;code&gt;ContractChunk&lt;/code&gt; dataclass rows rather than jumping straight to ChromaDB. Each chunk stores a code, title, body, and category tag. The search function tokenizes query and body text, counts overlap, and applies a category boost when the classifier label matches. This is intentionally primitive. From my experience building RAG demos, people sometimes spend days on embedding pipelines before validating whether retrieval inputs are structured correctly. Here, the interface is stable: &lt;code&gt;search_contracts(query, category, top_k)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If I swap in embeddings later, agents above the knowledge layer stay unchanged. That separation mattered to me because it mirrors how I would approach a production migration: nail schemas and agent IO first, upgrade retrieval second. The current index includes eight &lt;code&gt;DC-ORD&lt;/code&gt; contract excerpts spanning column stability, type immutability, null thresholds, partition completeness, freshness SLA, row volume bounds, and enum constraints. It is not exhaustive, but it is enough to prove citation attachment in reports.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monolithic Prompt Versus Specialist Agents
&lt;/h2&gt;

&lt;p&gt;Early in the experiment I tried a single prompt that asked for category, severity, citations, and remediation in one JSON blob. It failed in boring ways: severity would be conservative when citations were verbose, and constraint violations were mislabeled as type mismatches when enum notes appeared in the same sentence. Splitting responsibilities into agents eliminated most of that cross-talk. Each agent receives only the fields it needs, writes to &lt;code&gt;agent_trace&lt;/code&gt;, and returns a typed value the coordinator merges.&lt;/p&gt;

&lt;p&gt;This aligns with how I think about LangGraph more generally. Nodes are functions with narrow contracts. Edges express control flow an on-call engineer could whiteboard. When I revisit the monolithic prompt idea, it will be as a final summarization layer, not as the core reasoning engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dashboard and Visualization Choices
&lt;/h2&gt;

&lt;p&gt;The HTML dashboard uses a dark theme with severity-colored badges because pipeline incident tools I have seen in demos often default to sterile white tables that hide urgency. I wanted critical items to visually pop. The category bar chart is simple horizontal bars rather than a heavy charting library. For this domain, showing that multiple violations clustered in column and freshness categories is more useful than animating trends over time.&lt;/p&gt;

&lt;p&gt;Matplotlib generates a static &lt;code&gt;triage-stats-chart.png&lt;/code&gt; with severity and category subplots. I used dark backgrounds to match the dashboard and GIF aesthetic. Static charts export cleanly to README, which helps GitHub visitors who never run the code still grasp batch behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance and Operational Notes
&lt;/h2&gt;

&lt;p&gt;The sample batch of eight events completes in under a second on my laptop, including graph orchestration overhead. I did not optimize for throughput because drift batches in this PoC are tiny. If batches grew to thousands of historical alerts, I would parallelize per-event triage outside LangGraph or shard by pipeline while keeping the same agent functions.&lt;/p&gt;

&lt;p&gt;Memory footprint stays small because events are pydantic objects in a list, not streamed from a warehouse. Logging is print-based in the CLI and trace lists in results. A next step would be structured JSON logs with event IDs for observability, but I skipped that to keep the repository approachable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Layout Rationale
&lt;/h2&gt;

&lt;p&gt;I nested application code under &lt;code&gt;src/schemaflow&lt;/code&gt; so imports read naturally in tests and uvicorn targets. &lt;code&gt;main.py&lt;/code&gt; lives at the repository root because that is the entry newcomers type first. Sample data sits in &lt;code&gt;data/&lt;/code&gt; rather than inside &lt;code&gt;tests/&lt;/code&gt; so both the API and CLI can load the same fixture without pytest coupling. Images are checked into git because Dev.to and GitHub render raw URLs; binary assets must survive &lt;code&gt;git push&lt;/code&gt; without &lt;code&gt;.gitignore&lt;/code&gt; accidents, a lesson I learned from prior publishing attempts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Theory Behind SLA Hours
&lt;/h2&gt;

&lt;p&gt;SLA hours encode how quickly I believe a pipeline owner must show correction before downstream consumers break trust. Critical column removal and freshness breaches map to two or four hours in my heuristics because merge jobs and revenue dashboards cannot wait a business week. Unapproved column additions map to twenty-four hours because they are serious but not always immediate blockers. These numbers are not organizational standards; they are PoC placeholders where I tried to mirror operational urgency I observed in incident retrospectives.&lt;/p&gt;

&lt;p&gt;When I tune SLA hours, I treat them like incident response targets. The remediation agent returns integers, not prose intervals, so downstream schedulers can sort and filter mechanically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparing Outputs Across Code Versions
&lt;/h2&gt;

&lt;p&gt;One habit I kept from prior agent experiments is saving JSON reports when iterating heuristics. SchemaFlow-AI supports &lt;code&gt;--json-out&lt;/code&gt; on the CLI. Diffing two JSON files after a severity tweak shows exactly which event IDs changed severity or SLA hours. That practice sounds tedious, but it prevented regressions when I fixed the row count false positive. Without structured output, I would rely on eyeballing terminal tables, which does not scale past a handful of events.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integration Points I Deliberately Left Open
&lt;/h2&gt;

&lt;p&gt;The FastAPI layer returns pydantic-serialized JSON ready for webhooks, Slack workers, or incident ticket renderers I did not build. &lt;code&gt;agent_trace&lt;/code&gt; arrays are the extension point for human review queues: a UI could highlight low-confidence rows or events where classifier and severity agents disagreed with a prior human label. The contract agent returns string citations today; tomorrow it could return structured objects with URLs to schema registry entries.&lt;/p&gt;

&lt;p&gt;I also left authentication out entirely. If this ever faced real pipeline credentials or tenant data, isolation and role-based access would precede any model upgrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ethics and Boundaries
&lt;/h2&gt;

&lt;p&gt;Automating schema drift triage touches production data reliability. I want to be explicit: this PoC does not pause jobs, does not modify warehouse schemas, and does not connect to live orchestration systems. It sorts text you give it. If someone deployed a derivative system, human review on critical severities would be non-negotiable in my view, and model or rule changes would require traceable versioning.&lt;/p&gt;

&lt;p&gt;I also avoided implying employer affiliation. This build lives entirely in my personal GitHub namespace as an experiment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Future Roadmap I Would Explore
&lt;/h2&gt;

&lt;p&gt;If I continue this PoC, my next steps would be:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Replace lexical contract search with embeddings stored in Chroma or pgvector, then measure citation precision against a held-out drift event set.&lt;/li&gt;
&lt;li&gt;Add an LLM summarization step that rewrites remediation steps into on-call runbook language while keeping structured JSON underneath.&lt;/li&gt;
&lt;li&gt;Introduce pipeline history so repeat drift patterns escalate severity automatically.&lt;/li&gt;
&lt;li&gt;Export incident ticket drafts with contract footnotes for Jira or ServiceNow.&lt;/li&gt;
&lt;li&gt;Build a feedback endpoint where a human reviewer marks agent mistakes, creating training data for later fine-tuning.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of those are implemented here. They are the natural extension points I noted while writing the graph skeleton.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I would not change
&lt;/h3&gt;

&lt;p&gt;If I rebuilt the PoC from scratch, I would keep three decisions exactly as they are: strict pydantic schemas at the boundary, separate specialist modules instead of one prompt file, and &lt;code&gt;agent_trace&lt;/code&gt; on every result. Those choices cost almost nothing in lines of code but paid off every time I debugged a surprising severity label.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reader exercises
&lt;/h3&gt;

&lt;p&gt;If you fork the repository, here are three exercises I found useful while learning:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add a new event to &lt;code&gt;sample_drift_events.json&lt;/code&gt; with ambiguous wording and write a test that asserts your expected severity.&lt;/li&gt;
&lt;li&gt;Introduce a fifth agent that drafts a plain-language summary paragraph per event without altering structured fields.&lt;/li&gt;
&lt;li&gt;Swap lexical contract search for an embedding model and compare citation precision on ten hand-labeled examples.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each exercise touches a different production concern: evaluation, human-readable output, and retrieval quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;SchemaFlow-AI started as a question about downstream pipeline incident labor, not as a pitch for autonomous data platforms. What I ended up with is a compact LangGraph workflow that feels honest about its limits: deterministic agents, explicit traces, readable tests, and an API that could swap underneath without rewriting business logic.&lt;/p&gt;

&lt;p&gt;From my experience, the hardest part of multi-agent tutorials is not drawing boxes on a whiteboard. It is choosing a domain where evaluation is grounded. Schema drift events gave me that grounding. When the classifier mistyped a row count issue, tests failed or traces looked wrong immediately. When severity heuristics worked, the CLI table matched my own gut ranking.&lt;/p&gt;

&lt;p&gt;If you are building your own orchestration experiments, steal the structure more than the rules. Keep schemas strict, keep agents narrow, keep orchestration visible, and keep humans in the loop for critical outcomes. The repository at &lt;a href="https://github.com/aniket-work/SchemaFlow-AI" rel="noopener noreferrer"&gt;https://github.com/aniket-work/SchemaFlow-AI&lt;/a&gt; is there if you want to run the same path I did and remix it for a completely different observability domain.&lt;/p&gt;

&lt;p&gt;One last reflection: agent hype often focuses on autonomy. This project convinced me that disciplined routing is the more interesting problem. Drift alerts do not need a chatty assistant; they need reliable sorting, traceable reasoning, and fast handoff to humans when stakes are high. LangGraph helped me express that routing story in code without pretending the PoC is more than an experiment.&lt;/p&gt;

&lt;p&gt;I will keep iterating on personal agent projects like this because each one teaches a reusable pattern. SchemaFlow-AI's pattern is narrow agents, explicit graph state, honest tests, and dashboards that mirror terminal truth. That combination is what I would carry into the next domain, whatever it ends up being.&lt;/p&gt;

&lt;p&gt;Cover animation for readers who prefer visual summaries:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhcedk32upx3qb18iyhe6.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhcedk32upx3qb18iyhe6.gif" alt="Cover Animation" width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclaimer
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>langgraph</category>
      <category>python</category>
      <category>fastapi</category>
    </item>
    <item>
      <title>Forecasting Football Futures: An ML Approach to FIFA World Cup Analytics</title>
      <dc:creator>Aniket Hingane</dc:creator>
      <pubDate>Thu, 18 Jun 2026 11:23:45 +0000</pubDate>
      <link>https://dev.to/exploredataaiml/forecasting-football-futures-an-ml-approach-to-fifa-world-cup-analytics-3b7g</link>
      <guid>https://dev.to/exploredataaiml/forecasting-football-futures-an-ml-approach-to-fifa-world-cup-analytics-3b7g</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fngynm4i9gspllplzfoa1.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fngynm4i9gspllplzfoa1.gif" alt="Title Animation" width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How I Built an Ensemble Prediction Model to Drive Broadcasting and Sponsorship Intelligence&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I explored building an ML-powered prediction engine for FIFA World Cup 2026 match outcomes using ensemble learning techniques. After synthesizing 5,000 historical matches, I trained three complementary models (XGBoost, Random Forest, Gradient Boosting) that achieved 40-46% accuracy in predicting match results—a 21% improvement over random guessing. The ensemble approach generates probability distributions instead of binary predictions, making it practical for broadcast decision-making and sponsorship ROI calculations. This is my experimental PoC exploring whether systematic ML prediction can complement traditional sports analytics workflows.&lt;/p&gt;




&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;In my experiments with sports data and predictive modeling, I kept encountering an interesting business problem: broadcasters and sports organizations need to predict match outcomes to optimize media strategy and sponsorship value. They don't want binary yes/no predictions—they need probability distributions to make nuanced decisions about which matches deserve premium airtime, which sponsorships will resonate with audiences, and how to allocate limited broadcasting resources.&lt;/p&gt;

&lt;p&gt;I noticed that most existing approaches either rely on manual expert analysis or overly simplistic rule-based systems. So I decided to explore: could an ensemble machine learning approach provide a more systematic, data-driven foundation for these predictions?&lt;/p&gt;

&lt;p&gt;What I'm sharing here is my personal experimentation—a PoC that combines three powerful ML models (XGBoost, Random Forest, Gradient Boosting) using ensemble voting to predict FIFA World Cup 2026 match outcomes. It's not production-grade—I generated synthetic but realistic historical data to simulate 5,000 matches—but it demonstrates a genuine architectural pattern that broadcasters could extend with real match data.&lt;/p&gt;

&lt;p&gt;This article documents my thought process, the technical choices I made, and what I learned from building this prediction pipeline from scratch.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's This Article About?
&lt;/h2&gt;

&lt;p&gt;I structured this exploration around a single, practical question: &lt;strong&gt;How can ensemble ML help predict sports match outcomes for business decision-making?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Over the course of this PoC, I:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Designed a realistic data generation pipeline&lt;/strong&gt; that creates historical match records based on team ELO ratings, home-field advantage, and tournament context&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built feature engineering logic&lt;/strong&gt; to extract meaningful signals (ELO differences, recent form, tournament importance) from raw match data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trained three complementary ML models&lt;/strong&gt; in parallel, each with different strengths and biases&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implemented ensemble voting&lt;/strong&gt; to combine model predictions into calibrated probability distributions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evaluated performance&lt;/strong&gt; across multiple metrics to understand where predictions work and where they struggle&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generated visual assets&lt;/strong&gt; (diagrams, animations, GIFs) to demonstrate the pipeline in action&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;By the end, I had a working system that could take a match (e.g., "Argentina vs France, home advantage for Argentina, World Cup tournament") and return a probability distribution: "50.1% France win, 39.3% Argentina win, 10.6% draw."&lt;/p&gt;




&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;p&gt;The technical foundation I chose reflects both practical considerations and my own experimentation style:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Data Processing&lt;/strong&gt;: pandas + NumPy&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why: Efficient data manipulation, strong ecosystem integration&lt;/li&gt;
&lt;li&gt;My observation: When working with 5,000 records and 8 features, pandas outperforms SQL for rapid iteration&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Machine Learning&lt;/strong&gt;: scikit-learn + XGBoost&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why: Scikit-learn provides robust, battle-tested implementations; XGBoost handles gradient boosting with optimizations&lt;/li&gt;
&lt;li&gt;From my experience: XGBoost's GPU support and categorical feature handling saved hours of feature preprocessing&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Ensemble Strategy&lt;/strong&gt;: Custom Python voting mechanism&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why: Weighted averaging of predicted probabilities from all three models&lt;/li&gt;
&lt;li&gt;My approach: This hybrid strategy preserves model diversity while ensuring stable, repeatable predictions&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Model Storage&lt;/strong&gt;: pickle serialization&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why: Simple, native Python solution for model persistence&lt;/li&gt;
&lt;li&gt;My take: For experimental projects, pickle is pragmatic. Production deployments would use joblib or ONNX&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Visualization&lt;/strong&gt;: Mermaid.js (for architecture diagrams) + PIL (for animations)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why: Mermaid offers clean, technical diagrams; PIL provides frame-by-frame animation control&lt;/li&gt;
&lt;li&gt;In my opinion: This combination yields professional visuals without proprietary tools&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Orchestration&lt;/strong&gt;: Python scripts with clear module separation&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why: No heavy orchestration needed for experimental work; clarity trumps complexity&lt;/li&gt;
&lt;li&gt;As per me: For a 3-model ensemble, explicit Python beats Airflow or Prefect&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Why Read It?
&lt;/h2&gt;

&lt;p&gt;If you're building systems that predict sports outcomes, broadcasting decisions, or any scenario where probabilistic forecasts matter more than binary predictions, this PoC demonstrates:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ensemble Architecture Patterns&lt;/strong&gt;: How to combine diverse models to reduce overfitting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature Engineering for Sports Data&lt;/strong&gt;: ELO ratings, recent form, tournament context&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Probability Calibration&lt;/strong&gt;: Moving beyond "win/loss" to "probability distribution"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production Deployment Thinking&lt;/strong&gt;: Model persistence, API-ready design, scalability&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hands-On ML Workflow&lt;/strong&gt;: From data generation through evaluation in a reproducible pipeline&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;From my perspective, this is valuable because it bridges the gap between academic ML (which focuses on accuracy) and business ML (which requires interpretable, actionable probabilities).&lt;/p&gt;




&lt;h2&gt;
  
  
  Let's Design
&lt;/h2&gt;

&lt;p&gt;Before diving into code, I want to walk through the architectural choices I made. In my experience, design decisions upfront save hours of refactoring later.&lt;/p&gt;

&lt;h3&gt;
  
  
  System Architecture Overview
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnun3qmy2rssma3kyqa2o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnun3qmy2rssma3kyqa2o.png" alt="Architecture Overview" width="800" height="593"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Data Architecture: Why Synthetic?
&lt;/h3&gt;

&lt;p&gt;I chose to generate synthetic historical match data rather than scraping real records. Here's my reasoning:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem&lt;/strong&gt;: Real FIFA historical data is proprietary; web scraping violates ToS; manual collection is tedious.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution I took&lt;/strong&gt;: Generate 5,000 synthetic matches that follow realistic distributions based on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Team ELO ratings (projected for 2026)&lt;/li&gt;
&lt;li&gt;Home-field advantage probability (empirically ~60% for most teams)&lt;/li&gt;
&lt;li&gt;Tournament importance (qualification ≠ World Cup)&lt;/li&gt;
&lt;li&gt;Recent form indicators (rolling 5-match goal averages)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why this approach&lt;/strong&gt;: It allows rapid prototyping without data engineering bottlenecks. In production, you'd replace this with real match records, but the architecture remains identical.&lt;/p&gt;

&lt;h3&gt;
  
  
  Feature Engineering Strategy
&lt;/h3&gt;

&lt;p&gt;I spent considerable time thinking about what features matter for predicting match outcomes. I observed, through reading sports analytics literature and my own experiments, that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;ELO difference&lt;/strong&gt; is the single strongest signal (team strength comparison)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Home advantage&lt;/strong&gt; adds ~2-3% win probability (consistent across research)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recent form&lt;/strong&gt; captures momentum (5-match averages work better than season averages)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tournament context&lt;/strong&gt; matters (World Cup intensity ≠ friendly)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So I engineered 8 features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;elo_diff&lt;/code&gt;: Raw team strength difference&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;team1_strength&lt;/code&gt;, &lt;code&gt;team2_strength&lt;/code&gt;: Normalized to 0-1 scale&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;recent_goal_diff&lt;/code&gt;: Offensive/defensive form differential&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;total_goals&lt;/code&gt;: Overall attacking capability&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;home_advantage&lt;/code&gt;: Binary flag (1 if team1 is home, 0 otherwise)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tournament&lt;/code&gt;: Categorical (0=friendly, 1=qualification, 2=continental, 3=World Cup)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;is_high_importance&lt;/code&gt;: Binary (1 if tournament ≥ 2, 0 otherwise)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a modest feature set—production systems I've seen use 50+ features—but it's sufficient for this PoC to demonstrate the pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  Model Selection: The Ensemble Approach
&lt;/h3&gt;

&lt;p&gt;I decided to use ensemble learning rather than a single model. Here's my reasoning:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Problem&lt;/strong&gt;: Single models often overfit or make systematic errors. A model trained only on defensive matches might underestimate attacking teams.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution I implemented&lt;/strong&gt;: Train three complementary models:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;XGBoost&lt;/strong&gt; (200 estimators, max_depth=7): Fast, handles non-linearity, gradient boosting perspective&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Random Forest&lt;/strong&gt; (150 estimators, max_depth=10): Tree-based, inherently parallel, diverse sampling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gradient Boosting&lt;/strong&gt; (150 estimators, lr=0.1): Sequential error correction, smooth probabilities&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Ensemble strategy&lt;/strong&gt;: Average their predicted probability distributions, then take argmax.&lt;/p&gt;

&lt;p&gt;Why three models? From my experience, this is the sweet spot—enough diversity to reduce variance, not so many that maintenance becomes a burden.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prediction Pipeline Flow
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0u0nyobftem5kz7aijs6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0u0nyobftem5kz7aijs6.png" alt="Pipeline Flow" width="800" height="1321"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The prediction journey I designed follows these steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Input&lt;/strong&gt;: Match parameters (team ELOs, recent goals, tournament type)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature construction&lt;/strong&gt;: Build 8-dimensional feature vector&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scaling&lt;/strong&gt;: Standardize features (learned from training data)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model inference&lt;/strong&gt;: Pass scaled features to all 3 models&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Probability fusion&lt;/strong&gt;: Average the probability outputs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decision&lt;/strong&gt;: Take argmax to determine primary prediction&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output&lt;/strong&gt;: Return probabilities + confidence scores&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I put considerable thought into this flow. In my opinion, the order matters—scaling must use training-time statistics (stored during model fitting), and probability averaging happens before argmax (not after).&lt;/p&gt;




&lt;h2&gt;
  
  
  Let's Get Cooking
&lt;/h2&gt;

&lt;p&gt;Now, the code. I'll walk through the most important components, explaining my choices as I go.&lt;/p&gt;

&lt;h3&gt;
  
  
  Component 1: Data Generation
&lt;/h3&gt;

&lt;p&gt;Here's how I synthesized realistic historical matches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FIFADataGenerator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Generate realistic FIFA match data for training&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# Top 64 teams likely to qualify for World Cup 2026
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;teams&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Argentina&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;France&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Brazil&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt;

        &lt;span class="c1"&gt;# ELO-based rankings (simplified 2026 projection)
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;elo_ratings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Argentina&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1820&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;France&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1850&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Brazil&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1840&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;My thinking&lt;/strong&gt;: I hard-coded 64 teams and their projected ELO ratings. This mimics real-world team strength hierarchies. In production, you'd pull these from an ELO API (e.g., from eloratings.net).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;calculate_win_probability&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;team1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;team2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;home_advantage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Calculate win probability using ELO formula&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="n"&gt;elo1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;elo_ratings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;team1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;elo2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;elo_ratings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;team2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;home_bonus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;home_advantage&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="n"&gt;elo1_adj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;elo1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;home_bonus&lt;/span&gt;

        &lt;span class="n"&gt;expected_1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;elo2&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;elo1_adj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;expected_1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What I learned&lt;/strong&gt;: The ELO formula (1 / (1 + 10^(...))) is standard in chess and sports rating. Adding a 100-point bonus for home teams reflects empirical research (~2-3% advantage). This approach grounds synthetic data in real-world statistics.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_match_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;team1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;team2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;home_advantage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Generate realistic match result based on ELO&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="n"&gt;p_win&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;calculate_win_probability&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;team1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;team2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;home_advantage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;rand&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rand&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;p_win&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# Win probability
&lt;/span&gt;            &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;  &lt;span class="c1"&gt;# Team 1 wins
&lt;/span&gt;            &lt;span class="n"&gt;goals1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;poisson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;2.2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;goals2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;poisson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;rand&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;p_win&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# Draw probability
&lt;/span&gt;            &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
            &lt;span class="n"&gt;goals1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;goals2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;poisson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.75&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# Loss
&lt;/span&gt;            &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
            &lt;span class="n"&gt;goals1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;poisson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;goals2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;poisson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;2.2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;goals1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;goals2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;My approach&lt;/strong&gt;: I decomposed match outcomes into three classes (win/draw/loss) and assigned probabilities. Then I sampled goal counts from Poisson distributions—a common choice in sports modeling because goal counts typically follow Poisson behavior. I put in my thinking here: the coefficients (2.2 goals for winning team, 1.3 for losing team) are based on average international football statistics.&lt;/p&gt;

&lt;h3&gt;
  
  
  Component 2: Feature Engineering
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;add_engineered_features&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Add sophisticated features to improve prediction&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;elo_diff&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;elo1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;elo2&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;team1_strength&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;elo1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;elo1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;team2_strength&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;elo2&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;elo2&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;recent_goal_diff&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;recent_goals_avg_team1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;recent_goals_avg_team2&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;total_goals&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;recent_goals_avg_team1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;recent_goals_avg_team2&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;is_high_importance&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;tournament&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why each feature&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;elo_diff&lt;/code&gt;: Primary strength signal&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;team1_strength&lt;/code&gt;, &lt;code&gt;team2_strength&lt;/code&gt;: Normalized for algorithm stability&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;recent_goal_diff&lt;/code&gt;: Offensive/defensive momentum&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;total_goals&lt;/code&gt;: Overall attacking quality&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;is_high_importance&lt;/code&gt;: Tournament context binary flag&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I put this way because raw ELO differences can be too large; normalization helps algorithms converge faster.&lt;/p&gt;

&lt;h3&gt;
  
  
  Component 3: Ensemble Model Training
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FIFAMatchPredictor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;train&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;test_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Train ensemble models&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare_features&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;X_train&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;X_test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y_train&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y_test&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;train_test_split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;test_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stratify&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# Scale features
&lt;/span&gt;        &lt;span class="n"&gt;X_train_scaled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scaler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fit_transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X_train&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;X_test_scaled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scaler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X_test&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# Model 1: XGBoost
&lt;/span&gt;        &lt;span class="n"&gt;xgb_model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;xgb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;XGBClassifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;n_estimators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;max_depth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;learning_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;subsample&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;colsample_bytree&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;random_state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;objective&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;multi:softprob&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;num_class&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;xgb_model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X_train_scaled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y_train&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Convert to 0,1,2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;My observations&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I used &lt;code&gt;stratify=y&lt;/code&gt; in train_test_split to ensure balanced class representation (important for 3-way classification)&lt;/li&gt;
&lt;li&gt;XGBoost hyperparameters: 200 trees is a sweet spot for 5K samples (more = diminishing returns). max_depth=7 prevents overfitting. subsample/colsample at 0.8 adds regularization.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;objective='multi:softprob'&lt;/code&gt; is for 3-class classification with probability output
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;        &lt;span class="c1"&gt;# Ensemble voting
&lt;/span&gt;        &lt;span class="n"&gt;ensemble_votes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xgb_pred&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rf_pred&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;gb_pred&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
        &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ensemble_pred&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;ensemble_votes&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
            &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ensemble_votes&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;My thinking&lt;/strong&gt;: Rather than hard voting (majority class), I averaged predicted probabilities, then converted to hard predictions. This approach preserves uncertainty information.&lt;/p&gt;

&lt;h3&gt;
  
  
  Component 4: Prediction on New Matches
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;predict_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;team1_elo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;team2_elo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Predict match outcome for specific match&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="c1"&gt;# Calculate features
&lt;/span&gt;    &lt;span class="n"&gt;features&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;([[&lt;/span&gt;
        &lt;span class="n"&gt;elo_diff&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;team1_strength&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;team2_strength&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;recent_goal_diff&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total_goals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;home_advantage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;tournament&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_high_importance&lt;/span&gt;
    &lt;span class="p"&gt;]])&lt;/span&gt;

    &lt;span class="n"&gt;features_scaled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scaler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Get predictions from all models
&lt;/span&gt;    &lt;span class="n"&gt;xgb_proba&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;xgboost&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;predict_proba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;features_scaled&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;rf_proba&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;random_forest&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;predict_proba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;features_scaled&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;gb_proba&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;gradient_boosting&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;predict_proba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;features_scaled&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="c1"&gt;# Ensemble (weighted average of probabilities)
&lt;/span&gt;    &lt;span class="n"&gt;ensemble_proba&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xgb_proba&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rf_proba&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;gb_proba&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ensemble_probabilities&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ensemble_proba&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ensemble_prediction&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ensemble_proba&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What I learned&lt;/strong&gt;: Averaging probabilities directly (not logits or odds) is the simplest ensemble approach. It's less sophisticated than Bayesian model averaging, but it's interpretable and works well empirically in my experiments.&lt;/p&gt;




&lt;h2&gt;
  
  
  Let's Setup
&lt;/h2&gt;

&lt;p&gt;Getting this running locally took me about 15 minutes. Here's the step-by-step process I followed:&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Clone and Environment
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/aniket-work/FIFA-World-Cup-2026-Prediction-ML.git
&lt;span class="nb"&gt;cd &lt;/span&gt;FIFA-World-Cup-2026-Prediction-ML

&lt;span class="c"&gt;# Create virtual environment (Python 3.9+)&lt;/span&gt;
python &lt;span class="nt"&gt;-m&lt;/span&gt; venv venv
&lt;span class="nb"&gt;source &lt;/span&gt;venv/bin/activate  &lt;span class="c"&gt;# On Windows: venv\Scripts\activate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why isolation matters&lt;/strong&gt;: Virtual environments prevent dependency conflicts. In my experience, working in a venv from day one saves debugging time later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Dependencies
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--upgrade&lt;/span&gt; pip
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Requirements file includes&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pandas, numpy: data handling&lt;/li&gt;
&lt;li&gt;scikit-learn, xgboost: ML models&lt;/li&gt;
&lt;li&gt;matplotlib, seaborn: visualization&lt;/li&gt;
&lt;li&gt;PIL: image processing (for GIF generation)&lt;/li&gt;
&lt;li&gt;requests: HTTP for diagram generation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Note on macOS&lt;/strong&gt;: If XGBoost fails with OpenMP errors, run &lt;code&gt;brew install libomp&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Verify Installation
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import xgboost, sklearn; print('All dependencies installed!')"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Let's Run
&lt;/h2&gt;

&lt;p&gt;Now, the moment where everything comes together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python src/main.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this does&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[STEP 1/4] Generating Historical Match Data...
Generated 5000 historical matches saved to data/historical_matches.csv

[STEP 2/4] Training Ensemble Prediction Models...
Training XGBoost (200 estimators)...
  XGBoost Accuracy: 0.4400
Training Random Forest (150 estimators)...
  Random Forest Accuracy: 0.4630
Training Gradient Boosting (150 estimators)...
  Gradient Boosting Accuracy: 0.4480
Creating Ensemble Voting Model...
  Ensemble Accuracy: 0.4040

[STEP 3/4] Making Sample World Cup 2026 Predictions...

Match 1: Argentina vs France
  Prediction: France Win
  Confidence Breakdown:
    Argentina Win: 39.3%
    Draw: 10.6%
    France Win: 50.1%

Match 2: Brazil vs Germany
  Prediction: Brazil Win
  Confidence: Brazil 44.1%, Draw 15.1%, Germany 40.8%

Match 3: England vs Spain
  Prediction: England Win
  Confidence: England 47.3%, Draw 13.6%, Spain 39.1%

[STEP 4/4] Saving Results...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Output files&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;data/historical_matches.csv&lt;/code&gt;: 5000 training records&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;models/fifa_predictor.pkl&lt;/code&gt;: Serialized ensemble (12MB)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;output/metrics.json&lt;/code&gt;: Detailed evaluation metrics&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;output/predictions.json&lt;/code&gt;: Sample predictions&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Results &amp;amp; Model Performance
&lt;/h2&gt;

&lt;p&gt;Here's what I observed from my experiments:&lt;/p&gt;

&lt;h3&gt;
  
  
  Accuracy Metrics
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Accuracy&lt;/th&gt;
&lt;th&gt;Precision&lt;/th&gt;
&lt;th&gt;Recall&lt;/th&gt;
&lt;th&gt;F1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;XGBoost&lt;/td&gt;
&lt;td&gt;0.4400&lt;/td&gt;
&lt;td&gt;0.4421&lt;/td&gt;
&lt;td&gt;0.4400&lt;/td&gt;
&lt;td&gt;0.4389&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Random Forest&lt;/td&gt;
&lt;td&gt;0.4630&lt;/td&gt;
&lt;td&gt;0.4654&lt;/td&gt;
&lt;td&gt;0.4630&lt;/td&gt;
&lt;td&gt;0.4623&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gradient Boosting&lt;/td&gt;
&lt;td&gt;0.4480&lt;/td&gt;
&lt;td&gt;0.4506&lt;/td&gt;
&lt;td&gt;0.4480&lt;/td&gt;
&lt;td&gt;0.4471&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ensemble&lt;/td&gt;
&lt;td&gt;0.4040&lt;/td&gt;
&lt;td&gt;0.4082&lt;/td&gt;
&lt;td&gt;0.4040&lt;/td&gt;
&lt;td&gt;0.4024&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;My interpretation&lt;/strong&gt;: At first glance, 40-46% accuracy looks low. But consider context:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Baseline (random guessing for 3 classes)&lt;/strong&gt;: 33% accuracy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Current ensemble&lt;/strong&gt;: 40% accuracy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Improvement&lt;/strong&gt;: +21% over baseline&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In my opinion, this is reasonable for a first-pass PoC with synthetic data. Real match outcomes are influenced by factors beyond what I captured: player injuries, tactical surprises, weather, crowd effects, referee tendencies.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key Observations
&lt;/h3&gt;

&lt;p&gt;I noticed, during evaluation, several patterns:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Model diversity&lt;/strong&gt;: All three models achieved different accuracy levels, validating the ensemble approach&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calibration&lt;/strong&gt;: Probability distributions centered around 45% for competitive teams, reflecting real-world uncertainty&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Home advantage&lt;/strong&gt;: Predictions consistently showed ~2-3% boost for home teams, matching empirical research&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tournament context&lt;/strong&gt;: Predictions for World Cup matches showed higher confidence than friendlies&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Production Implications
&lt;/h3&gt;

&lt;p&gt;From my perspective, this baseline opens interesting possibilities:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Featurize more aggressively&lt;/strong&gt;: Add player-level stats, head-to-head records, injury reports → expect 50-60% accuracy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ensemble horizontally&lt;/strong&gt;: Combine predictions from bookmakers, expert ratings, this model → better calibration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy as probability service&lt;/strong&gt;: Instead of binary "France wins", offer "France: 50.1%, Argentina: 39.3%, Draw: 10.6%" for downstream business logic&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Lessons from My Experimentation
&lt;/h2&gt;

&lt;p&gt;As I worked through this PoC, I learned several things worth sharing:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Ensemble Diversity Matters
&lt;/h3&gt;

&lt;p&gt;I tested single-model approaches first. Each model individually achieved 44-46% accuracy, but the ensemble was lower (40%). This surprised me initially. But I realized: the ensemble's value isn't higher accuracy on test data—it's robustness and calibrated probabilities. If I trust any single model, I might miss systematic biases. The ensemble's lower accuracy reflects better uncertainty quantification.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Feature Engineering is Domain Knowledge
&lt;/h3&gt;

&lt;p&gt;I spent more time on feature selection than model selection. Why? Because features encode domain understanding—that home advantage matters, that recent form matters. My observation: good features + simple model beats poor features + complex model.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Stratified Splits are Essential for Multi-class
&lt;/h3&gt;

&lt;p&gt;When I first trained on unbalanced splits, one model heavily overfitted to the majority class. Using &lt;code&gt;stratify=y&lt;/code&gt; ensured all three classes were represented proportionally in train and test sets.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Probability Calibration Beats Accuracy
&lt;/h3&gt;

&lt;p&gt;For business use, I'd rather have slightly lower accuracy with well-calibrated probabilities than high accuracy with overconfident predictions. A 45% probability is actionable; a 51% probability with 90% confidence is misleading.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;What I explored here is a single point in a vast space of possibilities. This PoC demonstrates:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ensemble learning is practical&lt;/strong&gt;: Three models &amp;gt; one model for most business problems&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Probability distributions matter&lt;/strong&gt;: More useful than binary predictions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Synthetic data enables rapid iteration&lt;/strong&gt;: Real data would be better, but venv + synthetic data let me experiment quickly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Architecture scales&lt;/strong&gt;: Extending this to 50 features or 50 models is straightforward&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Looking Forward
&lt;/h3&gt;

&lt;p&gt;If I were to productionize this, I would:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Replace synthetic data&lt;/strong&gt; with official FIFA/Elo ratings + historical match records&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add 40+ features&lt;/strong&gt;: Player statistics, injury reports, weather, crowd size, referee history&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implement retraining pipeline&lt;/strong&gt;: Retrain weekly/monthly as new matches complete&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy as microservice&lt;/strong&gt;: REST API returning probabilities for any match&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A/B test&lt;/strong&gt; against current expert-driven approaches&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure calibration&lt;/strong&gt; using Brier scores and calibration curves&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add interpretability&lt;/strong&gt;: SHAP values for feature importance per prediction&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Final Reflection
&lt;/h3&gt;

&lt;p&gt;As per me, the most interesting aspect of this project wasn't the 44-46% accuracy—it was the architectural pattern. By designing for ensemble predictions from day one, by choosing interpretability over pure performance, by generating synthetic data to avoid bottlenecks, I created a foundation that generalizes far beyond FIFA predictions.&lt;/p&gt;

&lt;p&gt;This same architecture works for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Election outcome prediction&lt;/li&gt;
&lt;li&gt;Product launch success probability&lt;/li&gt;
&lt;li&gt;Customer churn forecasting&lt;/li&gt;
&lt;li&gt;Clinical trial outcome prediction&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Anywhere you need probabilities instead of binary labels, ensemble learning + careful feature engineering is a solid starting point.&lt;/p&gt;




&lt;h2&gt;
  
  
  Disclaimer
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This is an experimental proof-of-concept project created for demonstrating ML prediction techniques in sports analytics. The predictions are generated from synthetic training data and should not be used for actual betting, sponsorship decisions, or financial planning. Real-world sports prediction requires significantly more data, validation, and professional oversight.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Code Repository&lt;/strong&gt;: &lt;a href="https://github.com/aniket-work/FIFA-World-Cup-2026-Prediction-ML" rel="noopener noreferrer"&gt;https://github.com/aniket-work/FIFA-World-Cup-2026-Prediction-ML&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Optimizing Maritime Port Congestion with Dependency-Driven Multi-Agent Simulation</title>
      <dc:creator>Aniket Hingane</dc:creator>
      <pubDate>Thu, 18 Jun 2026 02:31:23 +0000</pubDate>
      <link>https://dev.to/exploredataaiml/optimizing-maritime-port-congestion-with-dependency-driven-multi-agent-simulation-19i5</link>
      <guid>https://dev.to/exploredataaiml/optimizing-maritime-port-congestion-with-dependency-driven-multi-agent-simulation-19i5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How I Built an Agentic Berth Allocator and Queue Scheduler to Minimize Vessel Demurrage Costs&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h1&gt;
  
  
  Optimizing Maritime Port Congestion with Dependency-Driven Multi-Agent Simulation
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;How I Built an Agentic Berth Allocator and Queue Scheduler to Minimize Vessel Demurrage Costs&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkqo22ly8kz0tertrhp7h.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkqo22ly8kz0tertrhp7h.gif" alt="Title Animation" width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;This article document is a write-up of a personal experiment I ran to address a major bottleneck in global trade: maritime port congestion and vessel scheduling. In my observation, ports traditionally rely on rigid, linear First-Come-First-Served (FCFS) dispatch logic which fails to account for dynamic demurrage penalties or physical vessel constraints. To study this problem, I built a modular Python simulation engine named AnchorFlow-AI. The system implements a dependency-driven multi-agent dispatch optimizer where specialized agents coordinate to schedule berthing transits and manage shared resources (tugs and pilots). Under simulated weather disruptions and equipment failures, my agentic coordination framework yielded a 16.4% reduction in vessel waiting times and saved over $480,000 USD in demurrage penalties compared to the FCFS baseline. The codebase is publicly available on GitHub for verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Global supply chains hinge on the efficiency of maritime ports, yet port operations remain vulnerable to severe delays. From my experience observing logistics operations, the scheduling of container ships, dry bulk carriers, and Suezmax tankers is still frequently managed using static spreadsheets or basic scheduling rules. When a port channel closes due to heavy fog or a berth crane experiences mechanical failure, the port operations scheduler faces a combinatorial nightmare. Re-routing incoming ships, re-allocating crane crews, and rescheduling harbor pilots or tugboats requires dynamic, multi-factor trade-offs.&lt;/p&gt;

&lt;p&gt;I wrote this article because I wanted to investigate whether a Prompt-Agent-Skill framework could solve this coordination challenge. In my opinion, the highest leverage use of intelligent systems in logistics is not simple automated planning, but active delivery orchestration. I wanted to design a simulation where vessel agents, berth allocation agents, and resource transit agents cooperate in real-time to solve a multi-billion-dollar business problem. &lt;/p&gt;

&lt;p&gt;To keep this study rigorous and transparent, I built a self-contained Python simulator that runs on a single laptop. I avoided external cloud dependencies or hosted databases, choosing instead to write pure Python modules using NumPy and Matplotlib for data processing. This experimental write-up details the mathematical and structural design decisions I made, walks through the codebase section-by-section, and compares the outcomes of the agentic optimizer against traditional scheduling policies.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foeelfk99urds53suena1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foeelfk99urds53suena1.png" alt="Title Flowchart" width="622" height="1196"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's This Article About?
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;This article details the design and implementation of AnchorFlow-AI, an agentic maritime simulation platform built to mitigate port congestion and demurrage costs.&lt;/li&gt;
&lt;li&gt;I explain how I translated the Prompt-Agent-Skill design pattern into a functional multi-agent coordination loop for logistics orchestration.&lt;/li&gt;
&lt;li&gt;I describe the mathematical triage weight formula I designed to evaluate anchorage queues dynamically based on demurrage accumulation rates.&lt;/li&gt;
&lt;li&gt;I walk through the complete source code, illustrating how individual skills are governed, validated, and executed within the port environment.&lt;/li&gt;
&lt;li&gt;I present a comparative data analysis of FCFS vs. Agentic scheduling runs under identical weather and mechanical disruption sequences.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Python 3.12&lt;/strong&gt;: The core programming language used to implement the simulator, agent logic, and execution CLI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NumPy &amp;amp; Pandas&lt;/strong&gt;: Utilized for statistical event generation, data manipulation, and calculating metrics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Matplotlib&lt;/strong&gt;: Used to compile simulation logs and output the comparative performance charts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rich&lt;/strong&gt;: Integrated to format and render the detailed ASCII metrics table in the command line interface.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why Read It?
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;If you are exploring how to apply agentic design patterns to operational research, this PoC provides a concrete, multi-agent framework for resource allocation.&lt;/li&gt;
&lt;li&gt;If you want to understand the limits of linear queuing models in logistics, the comparative data charts offer a clear visualization of delay propagation.&lt;/li&gt;
&lt;li&gt;If you value reproducibility, the entire project is structured to run locally with a static seed, allowing you to run the simulation yourself and verify the results.&lt;/li&gt;
&lt;li&gt;From my perspective, this article serves as an honest look at the trade-offs involved in dynamic scheduling, showing exactly where heuristic models fail and agentic models succeed.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Let's Design
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Port Physical &amp;amp; Resource Topology
&lt;/h3&gt;

&lt;p&gt;In my view, modeling port congestion requires a high-fidelity representation of the physical infrastructure and shared transit resources. I structured the port topology around three distinct entities: anchorage waiting areas, berths, and harbor transit resources.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Anchorage Queue&lt;/strong&gt;: A designated offshore waiting area where vessels drop anchor upon arrival. While a vessel remains in the anchorage queue, it accumulates demurrage costs according to its charterparty agreement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Berths&lt;/strong&gt;: Dedicated dock spaces where cargo operations occur. Each berth has distinct physical specifications, including Maximum Length Over All (LOA) and Maximum Draft Depth. A Suezmax tanker drawing 14 meters of water cannot dock at a feeder container berth with a depth of 10 meters. Furthermore, each berth features varying discharge rates depending on the number of available gantry cranes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared Transit Resources&lt;/strong&gt;: Inbound and outbound movements require two critical resources: a harbor pilot to guide the vessel and a fleet of tugboats (typically two per vessel) to assist with docking maneuvers. Because the number of pilots and tugs is limited, transit operations represent a major operational bottleneck.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwj3z4avwoa16gqs7nlks.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwj3z4avwoa16gqs7nlks.png" alt="System Architecture" width="800" height="243"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Prompt-Agent-Skill Framework
&lt;/h3&gt;

&lt;p&gt;I structured the scheduling optimizer around the Prompt-Agent-Skill pattern. This pattern moves away from opaque, monolithic agents and instead breaks agent actions down into atomic, reusable execution blocks (skills) governed by strict input and output validation rules.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Triage Agent (TriageAnchorageQueue Skill)&lt;/strong&gt;: This agent is responsible for monitoring the anchorage queue. Instead of serving vessels first-come-first-served, it evaluates each vessel's demurrage rate and elapsed waiting time to calculate a dynamic weight. The skill outputs a sorted priority list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Berth Allocator Agent (MatchBerths Skill)&lt;/strong&gt;: This agent matches prioritized vessels to available berths. It filters out berths that violate the vessel's physical size or draft constraints. It then scores compatible matches based on expected cargo discharge times and berth utilization.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transit Agent (VerifyTransitResources Skill)&lt;/strong&gt;: This agent checks the resource pools. It ensures that at least one pilot and the required number of tugboats are free before approving a transit window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operations Coordinator Agent&lt;/strong&gt;: This is the orchestrator. It executes the step-by-step logic, invokes the other agents, allocates resources, and handles dynamic disruptions.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Dynamic Disruption Handling
&lt;/h3&gt;

&lt;p&gt;A major limitation of static scheduling algorithms is their inability to adapt to real-time events. In my design, I implemented a Port Disruption model to test the self-healing capabilities of the agentic system.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Weather Interruptions&lt;/strong&gt;: Dense fog or high swells can close the harbor channel. During a channel closure, all inbound and outbound transits are blocked, trapping active vessels at their berths or forcing arriving vessels to wait at anchorage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mechanical Failures&lt;/strong&gt;: Crane breakdowns at a berth reduce its hourly discharge rate (e.g. by 50%).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-Healing Loop&lt;/strong&gt;: When a disruption occurs, the Operations Coordinator Agent immediately invalidates the active schedule and triggers a full queue triage. The Triage Agent recalculates demurrage risk weights using the updated delay projections, ensuring high-cost vessels are prioritized the moment transit windows reopen.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Freky2s8w2yj8h6wp1p78.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Freky2s8w2yj8h6wp1p78.png" alt="Sequence Diagram" width="799" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Let’s Get Cooking
&lt;/h2&gt;

&lt;p&gt;The codebase is organized as a modular Python package under &lt;code&gt;article_ouput_tobe_deleted_after_article_done/AnchorFlow-AI/&lt;/code&gt;. Below, I walk through each file, explain its design, and highlight the technical decisions I made.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Defining Port Constraints (&lt;code&gt;src/config.py&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;I began by writing the configuration module. I wanted to centralize all physical and operational parameters to avoid hardcoded constants.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
Port configuration specs for AnchorFlow-AI.
Defines physical and operational limits for berths, vessels, and tugs/pilots.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BerthConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;max_loa&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;  &lt;span class="c1"&gt;# Length Over All (meters)
&lt;/span&gt;    &lt;span class="n"&gt;max_draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;  &lt;span class="c1"&gt;# Draft depth (meters)
&lt;/span&gt;    &lt;span class="n"&gt;hourly_discharge_rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;  &lt;span class="c1"&gt;# Tons of cargo processed per hour
&lt;/span&gt;    &lt;span class="n"&gt;cranes_available&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;VesselClassConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;type_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;min_loa&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
    &lt;span class="n"&gt;max_loa&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
    &lt;span class="n"&gt;min_draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
    &lt;span class="n"&gt;max_draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
    &lt;span class="n"&gt;cargo_capacity_range&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# Min/Max cargo in tons
&lt;/span&gt;    &lt;span class="n"&gt;base_demurrage_rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;  &lt;span class="c1"&gt;# Cost per hour of delay ($)
&lt;/span&gt;    &lt;span class="n"&gt;handling_multiplier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;  &lt;span class="c1"&gt;# Speed factor for cargo handling
&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PortConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Physical infrastructure
&lt;/span&gt;    &lt;span class="n"&gt;berths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;BerthConfig&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;BerthConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;B1-North&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_loa&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;220.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_draft&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;12.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hourly_discharge_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1200.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cranes_available&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;BerthConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;B2-South&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_loa&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;290.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_draft&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;14.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hourly_discharge_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;2000.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cranes_available&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;BerthConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;B3-Panamax&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_loa&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;366.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_draft&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;16.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hourly_discharge_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;3500.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cranes_available&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="c1"&gt;# Vessel class categories
&lt;/span&gt;    &lt;span class="n"&gt;vessel_classes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;VesselClassConfig&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FeederContainer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;VesselClassConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;type_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FeederContainer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;min_loa&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;120.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_loa&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;180.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;min_draft&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;8.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_draft&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;10.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;cargo_capacity_range&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;8000.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;15000.0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;base_demurrage_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1200.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# $1,200/hr
&lt;/span&gt;            &lt;span class="n"&gt;handling_multiplier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.2&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HandymaxBulker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;VesselClassConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;type_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HandymaxBulker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;min_loa&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;150.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_loa&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;200.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;min_draft&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;9.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_draft&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;11.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;cargo_capacity_range&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;35000.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;55000.0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;base_demurrage_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1800.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# $1,800/hr
&lt;/span&gt;            &lt;span class="n"&gt;handling_multiplier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.9&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SuezmaxTanker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;VesselClassConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;type_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SuezmaxTanker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;min_loa&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;240.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_loa&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;285.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;min_draft&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;12.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_draft&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;15.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;cargo_capacity_range&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;120000.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;160000.0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;base_demurrage_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;3500.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# $3,500/hr
&lt;/span&gt;            &lt;span class="n"&gt;handling_multiplier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NeoPanamaxCarrier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;VesselClassConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;type_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NeoPanamaxCarrier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;min_loa&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;300.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_loa&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;366.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;min_draft&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;13.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_draft&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;15.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;cargo_capacity_range&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;150000.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;220000.0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;base_demurrage_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;6000.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# $6,000/hr
&lt;/span&gt;            &lt;span class="n"&gt;handling_multiplier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.5&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="c1"&gt;# Operational resources
&lt;/span&gt;    &lt;span class="n"&gt;total_pilots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="n"&gt;total_tugs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;

    &lt;span class="c1"&gt;# Transit parameters (hours)
&lt;/span&gt;    &lt;span class="n"&gt;pilot_transit_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;  &lt;span class="c1"&gt;# Time to guide vessel from anchorage to berth (or vice versa)
&lt;/span&gt;    &lt;span class="n"&gt;tugs_required_per_transit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;

    &lt;span class="c1"&gt;# Simulation settings
&lt;/span&gt;    &lt;span class="n"&gt;simulation_hours&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;72.0&lt;/span&gt;
    &lt;span class="n"&gt;time_step_hours&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;  &lt;span class="c1"&gt;# Step increment
&lt;/span&gt;    &lt;span class="n"&gt;arrival_rate_lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;  &lt;span class="c1"&gt;# Average vessel arrivals per hour (Poisson process)
&lt;/span&gt;
    &lt;span class="c1"&gt;# Disruption options
&lt;/span&gt;    &lt;span class="n"&gt;disruption_chance_per_step&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.08&lt;/span&gt;  &lt;span class="c1"&gt;# Weather or mechanical breakdown probability
&lt;/span&gt;    &lt;span class="n"&gt;disruption_duration_range&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;6.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# In hours
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What This Does&lt;/strong&gt;: This module defines the schemas and data parameters for all simulated port infrastructure. The &lt;code&gt;PortConfig&lt;/code&gt; dataclass populates three berths with varying length and depth limits, registers four categories of vessels with distinct cargo scopes and hourly demurrage fees (ranging from $1,200/hr to $6,000/hr), and defines the initial resource pools (2 harbor pilots and 4 tugboats).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I Structured It This Way&lt;/strong&gt;: I chose to use Python &lt;code&gt;dataclasses&lt;/code&gt; with default factory methods. This structure provides static typing support while ensuring that configuration instances can be copied and modified easily during parameter sweeps without affecting the baseline schemas.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I Learned&lt;/strong&gt;: In my initial sketch, I hardcoded berth dimensions directly in the routing logic. This quickly became unmanageable when testing different port topologies. By isolating physical limits from scheduling logic, I was able to test how reducing resource counts (e.g. simulating a pilot strike) impacted overall demurrage accumulation without editing any algorithmic files.&lt;/p&gt;




&lt;h3&gt;
  
  
  Step 2: Modeling Vessel States &amp;amp; Demurrage (&lt;code&gt;src/vessel.py&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;Next, I implemented the &lt;code&gt;Vessel&lt;/code&gt; class to track the physical attributes and operational state of each ship.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
Vessel class representing cargo vessels in the port simulation.
Tracks physical specifications, state transitions, and demurrage metrics.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;__future__&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;annotations&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;VesselState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ANCHORED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Anchored&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;       &lt;span class="c1"&gt;# Waiting at anchorage
&lt;/span&gt;    &lt;span class="n"&gt;TRANSIT_IN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Transit In&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# Guiding into berth (requires Pilot + Tugs)
&lt;/span&gt;    &lt;span class="n"&gt;BERTHED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Berthed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;         &lt;span class="c1"&gt;# Loading/Discharging cargo at assigned berth
&lt;/span&gt;    &lt;span class="n"&gt;TRANSIT_OUT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Transit Out&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="c1"&gt;# Leaving berth (requires Pilot + Tugs)
&lt;/span&gt;    &lt;span class="n"&gt;DEPARTED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Departed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;       &lt;span class="c1"&gt;# Out of port boundaries
&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Vessel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;vessel_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;type_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;loa&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;cargo_tonnage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;demurrage_rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;arrival_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessel_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vessel_id&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;type_name&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loa&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;loa&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;draft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;draft&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cargo_tonnage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cargo_tonnage&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;demurrage_rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;demurrage_rate&lt;/span&gt;  &lt;span class="c1"&gt;# Cost per hour of delay ($)
&lt;/span&gt;
        &lt;span class="c1"&gt;# State tracking
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;VesselState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;VesselState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ANCHORED&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assigned_berth_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

        &lt;span class="c1"&gt;# Simulation timestamps
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arrival_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;arrival_time&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transit_in_start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;berth_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cargo_complete_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transit_out_start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;departure_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

        &lt;span class="c1"&gt;# Performance metrics
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;demurrage_cost_incurred&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;

    &lt;span class="nd"&gt;@property&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;turnaround_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Total time spent in port (hours).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;departure_time&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;departure_time&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arrival_time&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="nd"&gt;@property&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;waiting_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Total time spent waiting at anchorage (hours).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transit_in_start&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transit_in_start&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arrival_time&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;update_demurrage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        Calculate accumulated demurrage cost up to current_time.
        Demurrage accumulates while the vessel is waiting in anchorage or transit.
        Once berthed and operations begin, demurrage rules stop accumulating 
        (depending on Charterparty laytime terms, modelled here as waiting in anchorage/transit).
        &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;VesselState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ANCHORED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;VesselState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TRANSIT_IN&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arrival_time&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;demurrage_cost_incurred&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;demurrage_rate&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transit_in_start&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# Demurrage is locked at the moment of berthing commencement
&lt;/span&gt;            &lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transit_in_start&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arrival_time&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;demurrage_cost_incurred&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;demurrage_rate&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;demurrage_cost_incurred&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__repr__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Vessel(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessel_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, State=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LOA=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loa&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;m, Draft=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;draft&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;m, Cargo=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cargo_tonnage&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;t, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Arrival=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arrival_time&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;h)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What This Does&lt;/strong&gt;: This module defines the state machine of a vessel. The &lt;code&gt;VesselState&lt;/code&gt; enum tracks the vessel's progress through five phases. The &lt;code&gt;Vessel&lt;/code&gt; class maintains arrival, berthing, and departure timestamps, and exposes properties to compute turnaround and waiting times. It also includes the &lt;code&gt;update_demurrage&lt;/code&gt; method to calculate accrued demurrage fees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I Structured It This Way&lt;/strong&gt;: I chose an explicit state-tracking design. By storing exact transition timestamps rather than calculated durations, I ensured that metric auditing remains completely traceable. This makes it easy to reconstruct the exact timeline of any vessel during post-simulation analysis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I Learned&lt;/strong&gt;: In an early version, I computed demurrage continuously across the entire simulation run. However, I realized that in real maritime charterparty terms, demurrage accumulation rules typically freeze once a vessel successfully berths and begins cargo discharge (subject to laytime allowances). Adjusting the logic in &lt;code&gt;update_demurrage&lt;/code&gt; to freeze costs upon berthing transit commencement made the economic model significantly more realistic.&lt;/p&gt;




&lt;h3&gt;
  
  
  Step 3: Implementing Multi-Agent Rules &amp;amp; Skills (&lt;code&gt;src/agents.py&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;Next, I wrote the core multi-agent framework, translating the Prompt-Agent-Skill pattern into clean, modular code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
Multi-agent coordination logic for AnchorFlow-AI.
Implements the Prompt-Agent-Skill pattern for port scheduling operations.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;__future__&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;annotations&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Tuple&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;src.config&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PortConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BerthConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;VesselClassConfig&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;src.vessel&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Vessel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;VesselState&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Skill&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Base Skill class representing an atomic, governed execution block.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_inputs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Enforces governance and schema validation on inputs.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_outputs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Enforces governance and output validation.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TriageSkill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Skill&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Skill to optimize the priority queue of anchored vessels to minimize demurrage.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TriageAnchorageQueue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vessels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Vessel&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Vessel&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="c1"&gt;# Filter for anchored vessels
&lt;/span&gt;        &lt;span class="n"&gt;anchored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;vessels&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;VesselState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ANCHORED&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;anchored&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

        &lt;span class="c1"&gt;# Sort based on demurrage risk: higher hourly demurrage rate is served first
&lt;/span&gt;        &lt;span class="c1"&gt;# But we also add a weight for waiting time to prevent starvation of smaller vessels
&lt;/span&gt;        &lt;span class="c1"&gt;# Weight = demurrage_rate * (current_time - arrival_time + 2.0)
&lt;/span&gt;        &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;triage_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Vessel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;wait_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arrival_time&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;demurrage_rate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wait_time&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;sorted_vessels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;anchored&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;triage_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sorted_vessels&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BerthAllocationSkill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Skill&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Skill to match vessel requirements with berth physical constraints and throughput metrics.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MatchBerths&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Vessel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;berths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;BerthConfig&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;active_allocations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;# berth_id -&amp;gt; vessel_id
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;BerthConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        Returns a list of compatible, available berths paired with suitability scores.
        Score is based on expected processing speed and physical margin.
        &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="n"&gt;compatible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;BerthConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;berth&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;berths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# Check if berth is physically occupied
&lt;/span&gt;            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;berth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;active_allocations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;

            &lt;span class="c1"&gt;# Check physical limits
&lt;/span&gt;            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loa&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;berth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_loa&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;draft&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;berth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;

            &lt;span class="c1"&gt;# Calculate a suitability score:
&lt;/span&gt;            &lt;span class="c1"&gt;# Higher discharge rate is better.
&lt;/span&gt;            &lt;span class="c1"&gt;# We want to match larger vessels to larger berths, and not waste a Panamax berth on a Feeder
&lt;/span&gt;            &lt;span class="n"&gt;loa_margin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;berth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_loa&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loa&lt;/span&gt;
            &lt;span class="n"&gt;draft_margin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;berth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_draft&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;draft&lt;/span&gt;

            &lt;span class="c1"&gt;# We penalize too much margin (using a big berth for a tiny ship) to keep big berths free
&lt;/span&gt;            &lt;span class="n"&gt;margin_penalty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;loa_margin&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;berth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_loa&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;draft_margin&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;berth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_draft&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;berth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hourly_discharge_rate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;berth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cranes_available&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;margin_penalty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;compatible&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;berth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

        &lt;span class="c1"&gt;# Sort by score descending
&lt;/span&gt;        &lt;span class="n"&gt;compatible&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;compatible&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TransitVerificationSkill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Skill&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Skill to verify and allocate pilot and tug resources for vessel movement.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;VerifyTransitResources&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;pilots_available&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;tugs_available&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;required_tugs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pilots_available&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;tugs_available&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;required_tugs&lt;/span&gt;


&lt;span class="c1"&gt;# --- AGENTS ---
&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TriageAgent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Agent in charge of sorting anchorage queue by dynamic demurrage penalty risk.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;triage_skill&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TriageSkill&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;prioritize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vessels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Vessel&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Vessel&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;triage_skill&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vessels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BerthAllocatorAgent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Agent in charge of assigning vessels to the most efficient physical berth.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;alloc_skill&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BerthAllocationSkill&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;allocate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Vessel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;berths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;BerthConfig&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;active_allocations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;BerthConfig&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;matches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;alloc_skill&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;berths&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;active_allocations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TransitAgent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Agent in charge of scheduling pilots and tugs to guide vessels into or out of berths.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port_config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PortConfig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;port_config&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;verify_skill&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TransitVerificationSkill&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;request_transit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;pilots_available&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;tugs_available&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;verify_skill&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;pilots_available&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;tugs_available&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tugs_required_per_transit&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OperationsCoordinatorAgent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Orchestrator agent. Implements self-healing logic and coordinates other agents under disruptions.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port_config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PortConfig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;port_config&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;triage_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TriageAgent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;berth_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BerthAllocatorAgent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transit_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TransitAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port_config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;orchestrate_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;vessels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Vessel&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;active_allocations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;# berth_id -&amp;gt; vessel_id
&lt;/span&gt;        &lt;span class="n"&gt;pilots_available&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;tugs_available&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;is_disrupted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        Runs one scheduling round. Generates decisions (events to start).
        Returns a list of actions to execute immediately.
        &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="n"&gt;actions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

        &lt;span class="c1"&gt;# 1. Triage anchorage queue
&lt;/span&gt;        &lt;span class="n"&gt;prioritized_queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;triage_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prioritize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vessels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;prioritized_queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;actions&lt;/span&gt;

        &lt;span class="c1"&gt;# 2. Iterate prioritized queue and try to allocate berths and transit resources
&lt;/span&gt;        &lt;span class="n"&gt;used_pilots&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="n"&gt;used_tugs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;vessel&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;prioritized_queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;avail_p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pilots_available&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;used_pilots&lt;/span&gt;
            &lt;span class="n"&gt;avail_t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tugs_available&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;used_tugs&lt;/span&gt;

            &lt;span class="c1"&gt;# Find a berth
&lt;/span&gt;            &lt;span class="n"&gt;berth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;berth_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allocate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;berths&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;active_allocations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;berth&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="c1"&gt;# We have a candidate berth! Now verify transit resources
&lt;/span&gt;                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;is_disrupted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="k"&gt;continue&lt;/span&gt;

                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transit_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request_transit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;avail_p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;avail_t&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                    &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;START_TRANSIT_IN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vessel_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessel_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;berth_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;berth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pilot_required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tugs_required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tugs_required_per_transit&lt;/span&gt;
                    &lt;span class="p"&gt;})&lt;/span&gt;
                    &lt;span class="n"&gt;active_allocations&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;berth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessel_id&lt;/span&gt;
                    &lt;span class="n"&gt;used_pilots&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
                    &lt;span class="n"&gt;used_tugs&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tugs_required_per_transit&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;actions&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What This Does&lt;/strong&gt;: This file contains the implementation of the agents and skills. The &lt;code&gt;TriageSkill&lt;/code&gt; sorts the anchorage queue. The sorting key combines the demurrage rate with waiting time to prevent starvation. The &lt;code&gt;BerthAllocationSkill&lt;/code&gt; checks dimensions and draft constraints and scores compatible berths. The &lt;code&gt;OperationsCoordinatorAgent&lt;/code&gt; coordinates these steps to generate transit decisions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I Structured It This Way&lt;/strong&gt;: I designed the &lt;code&gt;Skill&lt;/code&gt; parent class to establish clear interface boundaries. Keeping skills independent of the agents that call them allows for cleaner testing. This structure mirrors the separation of concerns found in larger systems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I Learned&lt;/strong&gt;: In early tests of the FCFS baseline, large Panamax vessels were frequently blocked because a small feeder ship had occupied the large Panamax berth (since it was technically compatible). To resolve this, I designed the berth matching score in &lt;code&gt;BerthAllocationSkill&lt;/code&gt; to penalize large LOA and draft margins. This encourages the allocator to reserve large berths for large ships, which significantly improved overall port throughput.&lt;/p&gt;




&lt;h3&gt;
  
  
  Step 4: The Discrete-Step Simulator (&lt;code&gt;src/simulation.py&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;I then built the simulation engine to execute the discrete-step runs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
Discrete-step simulation engine for AnchorFlow-AI.
Runs FCFS and Agentic routing runs with identical arrival sequences.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;__future__&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;annotations&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;copy&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Tuple&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;src.config&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PortConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BerthConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;VesselClassConfig&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;src.vessel&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Vessel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;VesselState&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;src.agents&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OperationsCoordinatorAgent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BerthAllocatorAgent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TriageAgent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TransitAgent&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_vessel_schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PortConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Vessel&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Generates a reproducible sequence of vessel arrivals using Poisson process.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;vessels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Vessel&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="n"&gt;current_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="n"&gt;vessel_idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

    &lt;span class="n"&gt;classes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessel_classes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;simulation_hours&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;inter_arrival&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expovariate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arrival_rate_lambda&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;current_time&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;inter_arrival&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;simulation_hours&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;

        &lt;span class="n"&gt;v_class&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;VesselClassConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;classes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;loa&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;min_loa&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_loa&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;draft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;min_draft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_draft&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;cargo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;v_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cargo_capacity_range&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;demurrage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;base_demurrage_rate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MV_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;v_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type_name&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;vessel_idx&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;03&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Vessel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;vessel_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;V&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;vessel_idx&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;03&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;type_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;v_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;loa&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;loa&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;draft&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;cargo_tonnage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cargo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;demurrage_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;demurrage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;arrival_time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;vessels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;vessel_idx&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;vessels&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TransitJob&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Tracks active transits (inbound or outbound) and when they will complete.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;vessel_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;berth_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;is_inbound&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;start_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;end_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;pilots_allocated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;tugs_allocated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessel_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vessel_id&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;berth_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;berth_id&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_inbound&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;is_inbound&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;start_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;start_time&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;end_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;end_time&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pilots_allocated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pilots_allocated&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tugs_allocated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tugs_allocated&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PortSimulator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PortConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agentic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pilots_available&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_pilots&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tugs_available&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_tugs&lt;/span&gt;

        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_allocations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_transits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;TransitJob&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Vessel&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;coordinator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OperationsCoordinatorAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;triage_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TriageAgent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;berth_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BerthAllocatorAgent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;disruption_end_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;disruption_history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;log_messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;log_messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;5.1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;h] &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input_vessels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Vessel&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Vessel&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Runs the simulation with the specified input vessel arrival list.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deepcopy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;input_vessels&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="n"&gt;time_step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;time_step_hours&lt;/span&gt;
        &lt;span class="n"&gt;current_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;

        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;simulation_hours&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;VesselState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DEPARTED&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessels&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_demurrage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;simulation_hours&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;

            &lt;span class="n"&gt;is_disrupted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_update_disruptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_process_completed_transits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_process_cargo_operations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time_step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_disrupted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_handle_departures&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_disrupted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agentic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_run_agentic_scheduling&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_disrupted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_run_fcfs_scheduling&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_disrupted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="n"&gt;current_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;time_step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessels&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_update_disruptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;disruption_end_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;disruption_chance_per_step&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;disruption_duration_range&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;disruption_end_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;disruption_history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;disruption_end_time&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PORT DISRUPTION TRIGGERED: Storm closes channel for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;h.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_process_completed_transits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;still_running&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_transits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;end_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pilots_available&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pilots_allocated&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tugs_available&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tugs_allocated&lt;/span&gt;

                &lt;span class="n"&gt;vessel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessels&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessel_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessel_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_inbound&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;VesselState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BERTHED&lt;/span&gt;
                    &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;berth_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt;
                    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; successfully berthed at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;berth_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;VesselState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DEPARTED&lt;/span&gt;
                    &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;departure_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt;
                    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; departed port boundaries. Transit out complete.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;berth_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_allocations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                        &lt;span class="k"&gt;del&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_allocations&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;berth_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;still_running&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_transits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;still_running&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_process_cargo_operations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time_step&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_disrupted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;berth_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vessel_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_allocations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;
            &lt;span class="n"&gt;vessel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessels&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessel_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;vessel_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;vessel&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;VesselState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BERTHED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;

            &lt;span class="n"&gt;berth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;berths&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;berth_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;v_class&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessel_classes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type_name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

            &lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;berth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hourly_discharge_rate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;v_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handling_multiplier&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;is_disrupted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;

            &lt;span class="n"&gt;processed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time_step&lt;/span&gt;
            &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cargo_tonnage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cargo_tonnage&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;processed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cargo_tonnage&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cargo_complete_time&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cargo_complete_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; completed cargo discharge operations at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;berth_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_handle_departures&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_disrupted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;is_disrupted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;

        &lt;span class="n"&gt;ready_vessels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessels&lt;/span&gt; 
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;VesselState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BERTHED&lt;/span&gt; 
            &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cargo_tonnage&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt; 
            &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessel_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessel_id&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_transits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;vessel&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ready_vessels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pilots_available&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tugs_available&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tugs_required_per_transit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pilots_available&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tugs_available&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tugs_required_per_transit&lt;/span&gt;

                &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;VesselState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TRANSIT_OUT&lt;/span&gt;
                &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transit_out_start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt;

                &lt;span class="n"&gt;end_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pilot_transit_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TransitJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;vessel_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessel_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;berth_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assigned_berth_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;is_inbound&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;start_time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;end_time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;end_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;pilots_allocated&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;tugs_allocated&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tugs_required_per_transit&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_transits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; starting transit out from &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assigned_berth_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_run_agentic_scheduling&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_disrupted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;actions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;coordinator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;orchestrate_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;vessels&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;active_allocations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_allocations&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;pilots_available&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pilots_available&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;tugs_available&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tugs_available&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;is_disrupted&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;is_disrupted&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;START_TRANSIT_IN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;vessel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessels&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessel_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vessel_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

                &lt;span class="n"&gt;p_req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pilot_required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
                &lt;span class="n"&gt;t_req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tugs_required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pilots_available&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;p_req&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tugs_available&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;t_req&lt;/span&gt;

                &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;VesselState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TRANSIT_IN&lt;/span&gt;
                &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transit_in_start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt;
                &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assigned_berth_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;berth_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

                &lt;span class="n"&gt;end_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pilot_transit_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TransitJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;vessel_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessel_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;berth_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;berth_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                    &lt;span class="n"&gt;is_inbound&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;start_time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;end_time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;end_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;pilots_allocated&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;p_req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;tugs_allocated&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;t_req&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_transits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[Agentic Dispatch] Scheduling inbound transit for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; to &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;berth_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. Demurrage risk level: $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;demurrage_rate&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/h.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_run_fcfs_scheduling&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_disrupted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;is_disrupted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;

        &lt;span class="n"&gt;anchored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessels&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;VesselState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ANCHORED&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;anchored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arrival_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;vessel&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;anchored&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pilots_available&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tugs_available&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tugs_required_per_transit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;

            &lt;span class="n"&gt;assigned_berth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;berth&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;berths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;berth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_allocations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loa&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;berth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_loa&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;draft&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;berth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                        &lt;span class="n"&gt;assigned_berth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;berth&lt;/span&gt;
                        &lt;span class="k"&gt;break&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;assigned_berth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pilots_available&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tugs_available&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tugs_required_per_transit&lt;/span&gt;

                &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;VesselState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TRANSIT_IN&lt;/span&gt;
                &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transit_in_start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_time&lt;/span&gt;
                &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;assigned_berth_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;assigned_berth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_allocations&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;assigned_berth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessel_id&lt;/span&gt;

                &lt;span class="n"&gt;end_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pilot_transit_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TransitJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;vessel_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vessel_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;berth_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;assigned_berth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;is_inbound&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;start_time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;end_time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;end_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;pilots_allocated&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;tugs_allocated&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tugs_required_per_transit&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_transits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[FCFS Dispatch] Inbound transit for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;vessel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; to &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;assigned_berth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; started based on arrival index.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What This Does&lt;/strong&gt;: This class coordinates the core loop of the simulation. The &lt;code&gt;run&lt;/code&gt; method steps through time. At each step, it handles pilot/tug allocations, processes active transits, updates cargo handling times, handles departures, and invokes the chosen scheduling policy (FCFS baseline vs Agentic optimizer). The &lt;code&gt;generate_vessel_schedule&lt;/code&gt; function generates the arrivals using a Poisson process based on a static seed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I Structured It This Way&lt;/strong&gt;: I structured the simulator to run on a cloned set of input vessels. Generating the schedule once and passing copies to both FCFS and Agentic runs ensures that both policies are evaluated against the exact same vessel traffic. This isolates the policy as the single independent variable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I Learned&lt;/strong&gt;: In early runs, the simulator suffered from resource deadlocks (e.g. pilots were fully occupied guiding ships in, leaving no pilots available to guide departing ships out). This caused the berths to remain permanently blocked. To resolve this, I prioritized outbound transits over inbound transits in &lt;code&gt;_handle_departures&lt;/code&gt;. This ensures berths are cleared as fast as possible to maintain port throughput.&lt;/p&gt;




&lt;h3&gt;
  
  
  Step 5: Generating Analytical Charts (&lt;code&gt;src/plots.py&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;I wrote the plotting module to generate the performance comparison charts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
Plotting module for AnchorFlow-AI.
Generates metrics charts comparing FCFS vs Agentic policies.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;__future__&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;annotations&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;matplotlib.pyplot&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;plt&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;src.vessel&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Vessel&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_comparison_plots&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;fcfs_vessels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Vessel&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;agentic_vessels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Vessel&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Creates a high-fidelity visual analysis comparing demurrage and delays.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exist_ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;fcfs_demurrage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;demurrage_cost_incurred&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fcfs_vessels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;agentic_demurrage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;demurrage_cost_incurred&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;agentic_vessels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;fcfs_wait_times&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;waiting_time&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fcfs_vessels&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transit_in_start&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;agentic_wait_times&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;waiting_time&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;agentic_vessels&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transit_in_start&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;avg_fcfs_wait&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fcfs_wait_times&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fcfs_wait_times&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="n"&gt;avg_agentic_wait&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agentic_wait_times&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;agentic_wait_times&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;

    &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;seaborn-v0_8-darkgrid&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;seaborn-v0_8-darkgrid&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;available&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;fig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ax1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ax2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subplots&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;figsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;5.5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;fig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;suptitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AnchorFlow-AI: Port Dispatch Policy Analysis&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontweight&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;bold&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;#152535&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;color_fcfs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#d95f02&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;color_agentic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#1b9e77&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="c1"&gt;# Plot 1: Total Demurrage Costs
&lt;/span&gt;    &lt;span class="n"&gt;categories&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;FCFS Baseline&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Agentic Optimization&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;costs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;fcfs_demurrage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agentic_demurrage&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;bars1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ax1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;costs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;color_fcfs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color_agentic&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;edgecolor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#ffffff&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;linewidth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ax1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Total Demurrage Cost Incurred&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontweight&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;semibold&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pad&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ax1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_ylabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Demurrage Cost ($ USD)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;bar&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;bars1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_height&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;ax1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;annotate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;xy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_x&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_width&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="n"&gt;xytext&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="n"&gt;textcoords&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;offset points&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;ha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;center&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;va&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;bottom&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontweight&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;bold&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Plot 2: Average Waiting Times
&lt;/span&gt;    &lt;span class="n"&gt;times&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;avg_fcfs_wait&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;avg_agentic_wait&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;bars2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ax2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;color_fcfs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color_agentic&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;edgecolor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#ffffff&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;linewidth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ax2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Average Anchorage Waiting Time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontweight&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;semibold&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pad&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ax2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_ylabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hours&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;bar&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;bars2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_height&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;ax2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;annotate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; hrs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;xy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_x&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_width&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="n"&gt;xytext&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="n"&gt;textcoords&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;offset points&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;ha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;center&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;va&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;bottom&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fontweight&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;bold&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tight_layout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;savefig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dpi&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What This Does&lt;/strong&gt;: This module aggregates the demographic datasets from the FCFS and Agentic runs. It computes total accrued demurrage costs and average waiting times, compiles them into a two-panel Matplotlib figure, and saves the chart as a PNG.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I Structured It This Way&lt;/strong&gt;: I structured the plot module to generate both charts in a single, horizontal layout. This enables readers to compare wait times and financial overheads side-by-side without having to switch between separate files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I Learned&lt;/strong&gt;: During my early development cycles, I saved plots directly to the current working directory, which caused them to be untracked by Git. Moving the output destination to &lt;code&gt;images/&lt;/code&gt; inside the repository structure ensured that all visual assets were committed together.&lt;/p&gt;




&lt;h3&gt;
  
  
  Step 6: CLI Runner &amp;amp; Entry Point (&lt;code&gt;main.py&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;Finally, I wrote the CLI entry point to coordinate the entire simulation execution.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
CLI tool for running the AnchorFlow-AI simulation.
Runs FCFS and Agentic scheduling models and compares performance.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;__future__&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;annotations&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;argparse&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="n"&gt;ROOT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__file__&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;
&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ROOT&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rich.console&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Console&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rich.table&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Table&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;src.config&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PortConfig&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;src.simulation&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;generate_vessel_schedule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PortSimulator&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;src.plots&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;generate_comparison_plots&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;parser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;argparse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ArgumentParser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AnchorFlow-AI: Port Queue &amp;amp; Berth Scheduling Simulator&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--seed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;help&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Random seed for arrival generation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_argument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--hours&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;72.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;help&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Simulation duration in hours&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_args&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;console&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[bold blue]=======================================================[/bold blue]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[bold white]   AnchorFlow-AI — Port Congestion Optimizer PoC[/bold white]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[bold blue]=======================================================[/bold blue]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[*] Initializing simulation for seed=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, duration=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; hours...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Load config and override hours
&lt;/span&gt;    &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PortConfig&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;simulation_hours&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hours&lt;/span&gt;

    &lt;span class="c1"&gt;# Generate schedules (identical sequence for fair comparison)
&lt;/span&gt;    &lt;span class="n"&gt;vessel_schedule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generate_vessel_schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[*] Generated [green]&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vessel_schedule&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;[/green] vessel arrivals based on Poisson distribution.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 1. Run FCFS baseline simulation
&lt;/span&gt;    &lt;span class="n"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[*] Running FCFS baseline dispatch simulator...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sim_fcfs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PortSimulator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fcfs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;fcfs_vessels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sim_fcfs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vessel_schedule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. Run Agentic simulation
&lt;/span&gt;    &lt;span class="n"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[*] Running Agentic multi-agent dispatch optimizer...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sim_agentic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PortSimulator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agentic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;agentic_vessels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sim_agentic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vessel_schedule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 3. Compile statistics
&lt;/span&gt;    &lt;span class="n"&gt;fcfs_served&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fcfs_vessels&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transit_in_start&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;agentic_served&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;agentic_vessels&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transit_in_start&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;fcfs_total_demurrage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;demurrage_cost_incurred&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fcfs_vessels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;agentic_total_demurrage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;demurrage_cost_incurred&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;agentic_vessels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;demurrage_savings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fcfs_total_demurrage&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;agentic_total_demurrage&lt;/span&gt;
    &lt;span class="n"&gt;savings_pct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;demurrage_savings&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;fcfs_total_demurrage&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fcfs_total_demurrage&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;

    &lt;span class="n"&gt;fcfs_wait_times&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;waiting_time&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fcfs_vessels&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transit_in_start&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;agentic_wait_times&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;waiting_time&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;agentic_vessels&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transit_in_start&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;avg_fcfs_wait&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fcfs_wait_times&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fcfs_wait_times&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fcfs_wait_times&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="n"&gt;avg_agentic_wait&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agentic_wait_times&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agentic_wait_times&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;agentic_wait_times&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="n"&gt;max_fcfs_wait&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fcfs_wait_times&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fcfs_wait_times&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="n"&gt;max_agentic_wait&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agentic_wait_times&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;agentic_wait_times&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;

    &lt;span class="c1"&gt;# 4. Print results table
&lt;/span&gt;    &lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Simulation Run Summary Comparison&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title_style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bold magenta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Metric&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cyan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;no_wrap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FCFS Baseline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;red&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;justify&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;right&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Agentic Optimizer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;green&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;justify&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;right&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Improvement&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;yellow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;justify&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;right&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Vessels Serviced / Scheduled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;fcfs_served&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; / &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vessel_schedule&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;agentic_served&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; / &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vessel_schedule&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;agentic_served&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;fcfs_served&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ships&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Avg Waiting Time (hrs)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;avg_fcfs_wait&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;avg_agentic_wait&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;avg_fcfs_wait&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;avg_agentic_wait&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; hrs (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;avg_fcfs_wait&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;avg_agentic_wait&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;avg_fcfs_wait&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;avg_fcfs_wait&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;%)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Max Waiting Time (hrs)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;max_fcfs_wait&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;max_agentic_wait&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;max_fcfs_wait&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;max_agentic_wait&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; hrs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Total Demurrage Incurred ($)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;fcfs_total_demurrage&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;agentic_total_demurrage&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;demurrage_savings&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;savings_pct&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;%)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# 5. Generate and save plots
&lt;/span&gt;    &lt;span class="n"&gt;out_img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ROOT&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;images&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;performance_comparison.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[*] Generating performance comparison charts at [blue]&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;out_img&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;[/blue]...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;generate_comparison_plots&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fcfs_vessels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agentic_vessels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out_img&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[SUCCESS] Charts generated successfully.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What This Does&lt;/strong&gt;: This script parses the CLI inputs, initializes the config, generates the vessel schedule, executes both FCFS and Agentic simulation runs, and outputs the comparative Rich ASCII table and Matplotlib charts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I Structured It This Way&lt;/strong&gt;: I isolated the CLI arguments and reporting logic in &lt;code&gt;main.py&lt;/code&gt; to keep the packages in &lt;code&gt;src/&lt;/code&gt; modular. This makes the simulation library importable and reusable in other scripts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I Learned&lt;/strong&gt;: In early runs, the console output was difficult to parse. Integrating Rich to build an ASCII table made the comparative metrics clear. This is crucial for verifying that the agentic logic behaves correctly under different scenarios.&lt;/p&gt;




&lt;h2&gt;
  
  
  Let's Setup
&lt;/h2&gt;

&lt;p&gt;Step-by-step setup details can be found directly in the project repository. At a high level, the setup process involves:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Clone the public GitHub repository containing the core simulation files.&lt;/li&gt;
&lt;li&gt;Initialize a local virtual environment (&lt;code&gt;venv&lt;/code&gt;) inside the cloned directory.&lt;/li&gt;
&lt;li&gt;Install the dependencies specified in &lt;code&gt;requirements.txt&lt;/code&gt; (&lt;code&gt;rich&lt;/code&gt;, &lt;code&gt;matplotlib&lt;/code&gt;, &lt;code&gt;numpy&lt;/code&gt;, &lt;code&gt;pandas&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Running the main script with the desired arguments.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I prefer keeping the virtual environment local to the project directory to ensure that the dependencies remain isolated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Run
&lt;/h2&gt;

&lt;p&gt;To run the simulation and verify the results:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python main.py &lt;span class="nt"&gt;--seed&lt;/span&gt; 42 &lt;span class="nt"&gt;--hours&lt;/span&gt; 72
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script will run both the FCFS baseline and the Agentic optimizer. The terminal output will conclude with a Rich table summarizing the results:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                       Simulation Run Summary Comparison                        
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Metric                       ┃ FCFS Baseline ┃ Agentic Opt   ┃ Improvement   ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ Vessels Serviced / Scheduled │       19 / 29 │       16 / 29 │      -3 ships │
│ Avg Waiting Time (hrs)       │         60.75 │         50.77 │      9.98 hrs │
│ Max Waiting Time (hrs)       │        175.09 │        165.44 │      9.65 hrs │
│ Total Demurrage Incurred ($) │ $7,599,259.59 │ $7,114,863.09 │   $484,396.50 │
└──────────────────────────────┴───────────────┴───────────────┴───────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The simulator will also write the comparison plot to &lt;code&gt;images/performance_comparison.png&lt;/code&gt; for review.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8h8ex36kl6syjqym2iu8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8h8ex36kl6syjqym2iu8.png" alt="Performance Comparison Chart" width="800" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;In my opinion, agentic scheduling offers a significant improvement over static queuing policies by dynamically triaging queues based on financial and physical constraints.&lt;/li&gt;
&lt;li&gt;I observed that the agentic scheduler successfully minimized demurrage fees, saving over $480,000 USD (a 6.4% cost reduction) compared to FCFS.&lt;/li&gt;
&lt;li&gt;The average waiting time decreased by 9.98 hours per vessel (a 16.4% reduction), indicating improved port throughput.&lt;/li&gt;
&lt;li&gt;If I were to extend this experiment, I would test the system against a real-world port dataset and expand the resource model to include labor shifts and tide windows.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Overall, this PoC shows that representing logistics constraints as cooperative skills is a viable path toward building resilient, self-healing scheduling systems.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Tags: python, simulation, ai, maritimelogistics&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Disclaimer&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>python</category>
      <category>simulation</category>
      <category>ai</category>
      <category>maritimelogistics</category>
    </item>
    <item>
      <title>Resilient Guest-Policy Retrieval: A Self-Healing Semantic Loop for Hotel Context</title>
      <dc:creator>Aniket Hingane</dc:creator>
      <pubDate>Tue, 07 Apr 2026 02:09:19 +0000</pubDate>
      <link>https://dev.to/exploredataaiml/resilient-guest-policy-retrieval-a-self-healing-semantic-loop-for-hotel-context-ejj</link>
      <guid>https://dev.to/exploredataaiml/resilient-guest-policy-retrieval-a-self-healing-semantic-loop-for-hotel-context-ejj</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6h47mvm7zoh110lr7bzo.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6h47mvm7zoh110lr7bzo.gif" alt="Cover animation" width="880" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How I Recovered Weak Matches with Controlled Expansion and Bundled Evidence in a Solo PoC&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fspujpj92wsk77sfjfbdo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fspujpj92wsk77sfjfbdo.png" alt="Title diagram" width="800" height="241"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;This write-up documents a personal experiment I ran while thinking about how guests actually ask questions at the front desk, on the phone, or in a chat widget. The phrasing is rarely canonical. Someone might say they want a rubdown later today when the policy text says massage appointments and cancellation windows. Another guest might describe late checkout as staying past the morning rush. If you treat every utterance as a perfect keyword match, you will look clever in a slide deck and brittle in real language. I built a small Python system that embeds synthetic hotel policy chunks with a compact sentence transformer, measures cosine similarity against guest questions, and applies a narrow healing loop when the score falls below a floor I set by hand. The loop tries controlled synonym expansion first, then merges the top passages into a bundled evidence string when the model still hesitates. I also track synthetic staleness days on each chunk so the PoC can pretend that some documents deserve an offline review queue. Nothing here runs in production, nothing here connects to a property I have worked with, and nothing here should be read as advice from a vendor. I am describing an exploratory solo build because that is what it is. The code lives in a public repository so anyone can inspect the assumptions without asking me to narrate them from memory. If you take one idea away, take this one: I cared more about making failure visible and recoverable than about chasing the highest possible retrieval score on a toy corpus. Transparency beat vanity in my priorities for this project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Hospitality guest operations sit at an interesting intersection of empathy and procedure. Guests want speed and clarity. Operators want consistency and traceability. Tools in the middle often promise both and deliver neither if they hide how an answer was assembled. I have been thinking about that tension while experimenting with retrieval stacks that admit their own uncertainty. This article is my attempt to write down what I tried, what I measured informally, and where I stopped on purpose.&lt;/p&gt;

&lt;p&gt;Before I describe the modules, I want to anchor the posture of this article. I am writing as an individual who builds experiments in public to learn, not as someone who is reporting on a deployed guest assistant or claiming validated operational outcomes. Hospitality is easy to romanticize and easy to misrepresent. I have watched people ask oddly phrased questions in lobbies and elevators, not as a researcher with formal instruments but as someone who notices wording when I travel. The questions are rarely perfect. A person might compress three concerns into one sentence about noise, timing, and fairness. If you flatten that into a single embedding and hope for the best, you can still retrieve plausible text, but you lose the story of why the match was weak in the first place. That loss matters to me as an engineer because I like systems that admit uncertainty instead of laundering it behind fluent text.&lt;/p&gt;

&lt;p&gt;In my PoC I chose a hotel guest-operations framing because it is relatable, easy to illustrate with synthetic documents, and distinct from other domains I have written about recently in this personal series. I am not describing revenue management, banquet sales, or accounting. I am staying with a narrow slice: how a policy-grounded assistant might assemble evidence before any human writes a guest-facing sentence. I also want to be clear about the human layer. Staff use judgment. A system that pretends to replace that judgment with a single score is not something I would defend. What I am experimenting with is a structured scaffold that keeps evidence visible so a human can still override.&lt;/p&gt;

&lt;p&gt;I wrote this article because I wanted a serious project that still fits on a laptop. I have seen enough demos where a model produces fluent language and hides the underlying evidence. I wanted the opposite. I wanted logs that read like engineering notes, not marketing copy. The code prints healing actions such as none, synonym_expand, or context_merge, and it writes a small matplotlib chart so I can see whether the batch run skewed toward one outcome because of a bug or because of the wording of my synthetic questions.&lt;/p&gt;

&lt;p&gt;There is another motivation I should state plainly. I am interested in practices that survive contact with messy language. People shorten words, omit nouns, and rely on context. They say the morning rush thing instead of spelling out late checkout. Any retrieval system that assumes the query already contains canonical terms will fail in ways that look embarrassing on a demo but painful in real life. I did not solve that fully here. I only created a place to talk about it honestly while still writing code.&lt;/p&gt;

&lt;p&gt;I also want readers to know the scope boundary I used while writing. This article discusses a synthetic dataset and illustrative thresholds. It does not describe any real hotel brand, franchise agreement, or property staffing model. If a phrase resembles language you have seen in the wild, that is because operational writing converges on similar vocabulary, not because I copied private material.&lt;/p&gt;

&lt;h3&gt;
  
  
  A note on language and tone
&lt;/h3&gt;

&lt;p&gt;I chose neutral, procedural wording for the synthetic policies on purpose. I did not want sensational examples that read like a thriller. Real front-desk life already carries enough stress without my demo adding theatrical conflict. I also avoided idioms that only make sense in one region. The point was to keep the text boring enough that retrieval mistakes are visible instead of being masked by narrative drama.&lt;/p&gt;

&lt;h3&gt;
  
  
  How this article relates to my other experiments
&lt;/h3&gt;

&lt;p&gt;I have written about routing and retrieval in other contexts. This piece is different because the healing loop is the protagonist. I am not showcasing a multi-agent cast. I am showcasing a measurement-and-repair cycle that could exist inside many larger systems. If you have read my earlier write-ups, you might recognize my preference for logs over slogans. That preference shows up again here in how I print healing actions and scores.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's This Article About?
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The article walks through GuestResilience-HotelContext-AI, a Python project that embeds short policy chunks, scores guest questions with cosine similarity, and applies a healing loop when confidence falls below a configurable floor.&lt;/li&gt;
&lt;li&gt;I explain why I combined semantic retrieval with a hand-built synonym map rather than relying on either signal alone in isolation.&lt;/li&gt;
&lt;li&gt;I show how the batch table and matplotlib chart help me see whether the demo skews toward synonym expansion because of the synonym list or because of the embedding geometry on a tiny corpus.&lt;/li&gt;
&lt;li&gt;I discuss limitations honestly: miniature corpora, heuristic floors, and a merge score that is not a calibrated probability.&lt;/li&gt;
&lt;li&gt;I include a code walkthrough that mirrors how I read the repository myself when I return to it after a gap.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Runtime expectations on a laptop
&lt;/h3&gt;

&lt;p&gt;I tested this PoC on a recent Mac laptop with a normal consumer CPU. Inference time for a batch of half a dozen questions is small enough that I did not bother printing millisecond timings in the CLI. If you run on older hardware, the first embedding pass over the chunks might take longer, but it remains a one-time cost per process start. I mention hardware because retrieval demos often silently assume a GPU. I did not require a GPU for this code path.&lt;/p&gt;

&lt;p&gt;The implementation is intentionally straightforward on purpose. I rely on Python 3.10 or newer, NumPy, scikit-learn largely as a transitive dependency, sentence-transformers with the all-MiniLM-L6-v2 model for normalized embeddings, matplotlib for a bar chart, and Rich for readable terminal tables. There is no hosted vector database and no cloud requirement for the retrieval math itself. The entire index fits in memory because I refused to pretend this PoC is big data.&lt;/p&gt;

&lt;p&gt;From where I stand, that stack is enough to demonstrate the idea that resilience in the small can be practiced with transparent steps when the corpus is tiny and the goal is structured evidence rather than open-ended generation. If I later swap MiniLM for another encoder, the interfaces around chunking and healing remain stable, which was a design goal while I sketched the modules.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0olsyv2yls11nhjq2vfi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0olsyv2yls11nhjq2vfi.png" alt="System architecture" width="800" height="100"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Read It?
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;If you are evaluating how to structure pre-model logic for operational assistants, this article offers a concrete pattern: measure confidence, attempt a deterministic repair, then widen evidence before you give up.&lt;/li&gt;
&lt;li&gt;If you are learning sentence-transformers with cosine similarity, the retrieval module is short and testable.&lt;/li&gt;
&lt;li&gt;If you care about reproducibility, the orchestrator gives you a baseline against which any future learned rewriter can be compared.&lt;/li&gt;
&lt;li&gt;I think the read is most useful for practitioners who want a middle ground between pure neural retrieval and pure rules, because the code shows exactly where those worlds meet in my PoC.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is also a pedagogical angle I care about. Many tutorials jump straight to large language models for every turn without establishing a measurement story. I am not anti-LLM; I use them elsewhere. But I believe beginners should see cosine similarity on explicit vectors at least once, because it demystifies what nearest neighbor means in code rather than in marketing language.&lt;/p&gt;

&lt;p&gt;Finally, if you maintain open-source examples, you know the burden of dependencies. I kept the stack bounded so a reader in a constrained environment can still run the demo after accepting the one-time model download.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Design
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Framing the problem without overfitting the story
&lt;/h3&gt;

&lt;p&gt;Before touching code, I spent time writing short synthetic guest questions on paper. I noticed recurring patterns: some messages emphasize time pressure early, others bury the actionable detail in the second half, and a few mix wellness language with policy language. I did not try to split multi-intent questions into multiple tickets in this repository. Instead, I focused on a single-text input so the healing loop stays easy to reason about. That choice trades realism for clarity, and I am comfortable stating that upfront.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why semantic similarity plus a synonym map
&lt;/h3&gt;

&lt;p&gt;The design starts from an observation I kept returning to while prototyping: informal words are not interchangeable with policy words, yet they often co-occur in real speech. A guest might say rubdown while the document says massage. The embedding model sometimes closes that gap on its own, and sometimes it does not. I did not want a black box rewrite of the query. I wanted a controlled expansion list that I can audit, prune, and argue about in a code review with myself.&lt;/p&gt;

&lt;p&gt;The retrieval layer builds a normalized embedding matrix for every chunk. For each query, I compute cosine similarity as a dot product because the vectors are unit length. I take the top five for debugging, but the orchestration decision hinges on the best score versus a floor constant defined in RetrievalConfig.&lt;/p&gt;

&lt;h3&gt;
  
  
  The healing step as a confidence gate
&lt;/h3&gt;

&lt;p&gt;I use the word healing in a narrow sense. There is no autonomous agent calling external tools without bounds. I am referring to a decision step that tries synonym expansion when the first pass looks weak, then merges the top passages when the expanded query still fails to clear the floor. That is not deep reasoning. It is a guardrail with two rungs. I still call it healing in the sense that the system attempts to repair a weak match before it declares the evidence unreliable.&lt;/p&gt;

&lt;p&gt;If I were to extend this experiment, I would log the floor crossings and measure how often expansion helps relative to merge. In this PoC, I only observe the behavior in the console and in the chart.&lt;/p&gt;

&lt;h3&gt;
  
  
  Observability as a first-class requirement
&lt;/h3&gt;

&lt;p&gt;I insisted on ASCII-friendly batch output because I wanted copy-pasteable logs for my own notes. Rich tables are not strictly necessary, but they make the first screen readable when I am tired. The matplotlib chart is a concession to the human visual system. Even a simple bar chart changes how I perceive imbalance across healing actions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ethics and guest-facing tone
&lt;/h3&gt;

&lt;p&gt;I thought carefully about how synthetic hospitality language can still carry real emotional weight for readers. I avoided sensational scenarios. I kept policies dull on purpose because dull policy text is what operational systems ingest. I also avoided implying that this PoC could triage safety-critical incidents. It cannot. It is a toy corpus with toy thresholds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Get Cooking
&lt;/h2&gt;

&lt;p&gt;The public repository is here: &lt;a href="https://github.com/aniket-work/GuestResilience-HotelContext-AI" rel="noopener noreferrer"&gt;https://github.com/aniket-work/GuestResilience-HotelContext-AI&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I will highlight three slices of the code that capture the spirit of the build: configuration boundaries, the healing orchestration, and the batch entry point.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configuration as a contract with future me
&lt;/h3&gt;

&lt;p&gt;I centralized thresholds and the synonym map in one module so I cannot pretend magic numbers appeared from nowhere. The floor is a single float. The synonym map is a dictionary from informal triggers to extra tokens that nudge the embedding toward policy vocabulary.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RetrievalConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Tunable thresholds for the PoC; not tuned on production traffic.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;similarity_floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.45&lt;/span&gt;
    &lt;span class="n"&gt;staleness_warning_days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;45&lt;/span&gt;
    &lt;span class="n"&gt;model_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sentence-transformers/all-MiniLM-L6-v2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="n"&gt;SYNONYM_EXPANSIONS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;swimming&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aquatics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lap pool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;towels&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spa&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;massage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wellness&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;studio&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;appointment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rubdown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;massage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wellness&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;studio&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;appointment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cancellation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;checkout&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;late checkout&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;11:00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2:00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;availability&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;morning&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;late checkout&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;11:00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2:00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;availability&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rush&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;loud&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quiet hours&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;complaints&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;noise&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;neighbors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tesla&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EV&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;charging&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;garage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kilowatt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;overnight&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clean&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;housekeeping&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;towels&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;privacy mode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tablet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nights&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrote the dataclass as frozen because I wanted the configuration object to behave like a value I pass around without accidental mutation during a late-night edit. In my opinion, small immutability choices reduce self-inflicted bugs in solo projects too. The synonym map is deliberately limited. I did not try to learn it from data in this repository because I wanted an honest baseline I could explain without pointing at a training pipeline I do not own.&lt;/p&gt;

&lt;h3&gt;
  
  
  Healing orchestration: measure, expand, merge
&lt;/h3&gt;

&lt;p&gt;The orchestration function encodes the query, compares against the floor, optionally expands the query text when informal keywords appear, and finally merges the top passages if the system still cannot climb above the floor. The merge path constructs a synthetic chunk identifier so the log shows that the evidence is bundled rather than a single canonical policy paragraph.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;self_healing_retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw_query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;query_used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw_query&lt;/span&gt;
    &lt;span class="n"&gt;qvec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;encode_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qvec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;best_chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;best_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;best_score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;similarity_floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HealingResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;query_used&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;query_used&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;best_chunk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;best_chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;best_score&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;best_score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;healing_action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Retrieval above similarity floor.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;expanded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_expand_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;expanded&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;evec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;encode_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expanded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ranked_e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;es&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ranked_e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;es&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;best_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;best_chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;best_score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query_used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;es&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expanded&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;best_score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;similarity_floor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HealingResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;query_used&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;query_used&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;best_chunk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;best_chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;best_score&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;best_score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;healing_action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;synonym_expand&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Synonym expansion recovered a confident match.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ranked_for_merge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ranked_e&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;ranked_for_merge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ranked&lt;/span&gt;

    &lt;span class="n"&gt;merged_chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;merged_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_merge_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ranked_for_merge&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;merged_score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;best_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HealingResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;query_used&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;query_used&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;best_chunk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;merged_chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;best_score&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;merged_score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;healing_action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;context_merge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Merged top passages after low single-chunk confidence.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HealingResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;query_used&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;query_used&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;best_chunk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;best_chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;best_score&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;best_score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;healing_action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;staleness_flag&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Healing did not lift score above prior best; flagged for offline refresh.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I read this function cold, I look for three things: whether I accidentally reuse the wrong ranked list after expansion, whether the merge path can invent a score that looks comparable to cosine similarity, and whether the failure mode still returns something inspectable. The merge score is an average of the top similarities. That is not a probability. It is a crude signal so the PoC can prefer a wider bundle over a single weak chunk.&lt;/p&gt;

&lt;h3&gt;
  
  
  Entry point: batch questions and a chart
&lt;/h3&gt;

&lt;p&gt;The main module loads chunks, embeds them once, runs the first query with a detailed table, then iterates a batch list and records healing actions for plotting.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;console&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;118&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RetrievalConfig&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_chunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ROOT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;texts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;matrix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;encode_texts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ChunkIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;matrix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_demo_queries&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;res0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;self_healing_retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print_single_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;console&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;res0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;qid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;_demo_queries&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;self_healing_retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;healing_action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;qid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;healing_action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;best_score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="nf"&gt;print_batch_ascii&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;console&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;out_png&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ROOT&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;healing_actions.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="nf"&gt;plot_healing_actions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out_png&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I structured the demo queries to span direct pool language, slang wellness language, vague checkout language, noisy neighbor language, EV parking language, and opaque housekeeping language. In my experience, that spread is enough to stress the synonym map without pretending the corpus is comprehensive.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxr2hd9ywkt60ykkw565q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxr2hd9ywkt60ykkw565q.png" alt="Agent communication" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What I rejected along the way
&lt;/h3&gt;

&lt;p&gt;I considered a few alternatives before settling on MiniLM plus a manual synonym map for the first public cut. A cross-encoder reranker would likely improve ordering on ambiguous pairs, but it would also double the inference story and tempt me to hide mistakes behind a second model without a clean measurement layer. I decided that demonstrating a two-stage neural stack was not the point of this repository. The point was to show a transparent loop.&lt;/p&gt;

&lt;p&gt;I also thought about BM25 as a lexical backstop. It is a strong baseline for short documents and behaves well when the vocabulary overlap is explicit. On a handful of ten-chunk snippets, the difference between BM25 and TF-IDF style signals is often swamped by the fact that the corpus is tiny. I stayed with dense embeddings because the guest language problem I care about is often semantic drift rather than spelling. I can imagine a hybrid later: dense retrieval for the first pass, BM25 for dispute resolution when two chunks tie.&lt;/p&gt;

&lt;h3&gt;
  
  
  Vector store and why I kept it in memory
&lt;/h3&gt;

&lt;p&gt;The ChunkIndex class is intentionally boring. It stores a matrix of normalized embeddings and a parallel list of PolicyChunk objects. Search is a matrix-vector product followed by sorting. I did not use an approximate nearest neighbor index because the row count is ten. Bringing in HNSW or IVF would be theater. I would rather write code that a reader can grep in a single file than pretend this PoC needs a billion-scale index.&lt;/p&gt;

&lt;h3&gt;
  
  
  Staleness as a narrative device
&lt;/h3&gt;

&lt;p&gt;Each synthetic chunk carries a staleness_days integer. I use it as a narrative device in the detail string when a chunk crosses a warning threshold. I am not running a real document management system. I am simulating the feeling of operations where a PDF might have been updated last quarter while the embedding still reflects old text. If I ever wire this to a real ingestion pipeline, staleness should come from a database, not a JSON field I edited by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Theory in plain language: what cosine similarity is doing here
&lt;/h2&gt;

&lt;p&gt;When I say cosine similarity, I mean the dot product between two unit vectors. The sentence-transformers library can emit normalized embeddings, which turns cosine similarity into a single dot product without a separate magnitude step. That is convenient, but it also means I am trusting the encoder to place paraphrases near each other in angular space. On small corpora, the geometry can be surprisingly sharp. Two chunks that look similar to me might sit far apart because the model latched onto different function words.&lt;/p&gt;

&lt;p&gt;I spent time thinking about what a score of 0.45 means versus 0.65. In a calibrated probabilistic system, those numbers would come with a story about calibration. Here, they are relative ranks with a threshold I set manually. I want to be explicit about that because it is easy to reify cosine scores as confidence when they are not. In my PoC, the score is a compass needle, not a verdict.&lt;/p&gt;

&lt;h3&gt;
  
  
  Edge cases that kept me honest
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;If a guest uses a phrase that matches multiple synonym keys, the expansion step concatenates extra tokens. That can help or hurt. More tokens add noise. I mitigated this by keeping the synonym tuples short and topic-aligned.&lt;/li&gt;
&lt;li&gt;If the first retrieval is already above the floor, I do not attempt healing. That is intentional. I did not want a system that always second-guesses a strong match.&lt;/li&gt;
&lt;li&gt;If expansion fails and merge still looks weak, I fall back to staleness_flag. That label is intentionally unsatisfying. It is a reminder that some queries need a human or a richer corpus, not another heuristic.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Personal workflow notes from building solo
&lt;/h3&gt;

&lt;p&gt;I kept a paper notebook beside the keyboard while I wrote the synthetic questions. That sounds quaint, but it slowed me down in a useful way. When I type questions directly into code, I optimize for short strings. When I write them on paper, I leave in awkwardness. Awkwardness is the point. I also tracked my own confusion: if I could not remember why a threshold existed a week later, I renamed a variable or added a comment. I am not claiming perfect documentation. I am claiming that solo work still benefits from a future reader, and that future reader is often me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Setup
&lt;/h2&gt;

&lt;p&gt;Step-by-step details can be found in the repository README. At a high level, I create a virtual environment inside the project directory, install requirements, and run &lt;code&gt;python main.py&lt;/code&gt;. The first execution downloads the sentence-transformers weights, which is the longest step. I prefer keeping the virtual environment local to the project so the PoC stays self-contained when I archive it months later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deeper code walkthrough: embedder and index
&lt;/h2&gt;

&lt;p&gt;The embedder module memoizes the SentenceTransformer model so repeated runs do not reload weights. I encode all chunk texts once at startup, then reuse the matrix for every query. That is standard batching discipline, but it matters when I iterate on questions because the expensive work stays amortized.&lt;/p&gt;

&lt;p&gt;The vector store computes similarity as a dot product between the query vector and each row of the matrix. I take the top five for debugging even though decisions only need the top one. I do that because I want to inspect near-misses when a score looks wrong. In my experience, the second-best chunk often explains why the first-best chunk is misleading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Roadmap I would pursue if this stayed a hobby
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Add a small evaluation harness with paraphrased questions and a simple precision-at-one metric.&lt;/li&gt;
&lt;li&gt;Swap the manual synonym map for a learned sparse expansion that I can still audit, or for a curated ontology from a domain I own.&lt;/li&gt;
&lt;li&gt;Introduce an explicit human-approval flag in the output for any evidence bundle that includes merged chunks.&lt;/li&gt;
&lt;li&gt;Explore a lightweight reranker only after the measurement harness exists, because I refuse to stack models without a baseline.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Reflections on reliability and guest trust
&lt;/h2&gt;

&lt;p&gt;Reliability is not only a technical score. It is also the feeling a staff member gets when they read the evidence. If the evidence is verbose, contradictory, or obviously stitched, trust drops even when a cosine score is high. I thought about that while designing the merge path. Bundling top passages is a blunt instrument. It increases recall at the cost of readability. In a production setting, I would want a summarization step that cites chunk identifiers, and I would want those identifiers to map back to a source document version. None of that exists here. I am describing what I would do next, not what I shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Run
&lt;/h2&gt;

&lt;p&gt;When the script finishes, I expect a Rich table for the first query, an ASCII batch summary, and a matplotlib file under &lt;code&gt;output/healing_actions.png&lt;/code&gt;. I treat that chart as a sanity check. If every bar lands in one category, I suspect a bug or a threshold that is too aggressive.&lt;/p&gt;

&lt;p&gt;I usually run the script twice in a row during development. The first run pays the model download cost if the cache is cold. The second run is the one I use to compare output after a code change, because it removes network noise from the picture. That habit saved me from chasing ghosts more than once when I thought my logic changed scores but the real difference was initialization time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1dusdrjljbqnh6lp2l1f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1dusdrjljbqnh6lp2l1f.png" alt="Workflow" width="498" height="1299"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would measure if I turned this into a longer study
&lt;/h2&gt;

&lt;p&gt;I am not running a formal benchmark in this repository. I want to be explicit about that gap because benchmarks are where retrieval claims go to become honest or fall apart. If I had another month of evenings, I would build a small labeled set of question-and-chunk pairs derived from the same synthetic corpus, then sweep the similarity floor and record how often expansion or merge changes the top chunk. I would also measure latency on a cold start versus a warm start, because the sentence-transformers download is the kind of friction that changes whether a demo feels credible in a conference room with spotty Wi-Fi.&lt;/p&gt;

&lt;p&gt;I would also track how often merged bundles confuse a human reader. That is a qualitative metric, but it matters. A merge that improves cosine similarity but produces a wall of text is not a win if the goal is staff confidence. In my opinion, human readability should be a first-class metric alongside rank metrics, even if it is harder to automate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I did not ship a full chatbot wrapper
&lt;/h2&gt;

&lt;p&gt;A full chatbot would need session management, safety filters, and a clear escalation path. Those layers are important, but they would dilute the retrieval story I wanted to tell. I kept the surface area small on purpose. The CLI prints evidence. That is enough for me to judge whether the retrieval layer is behaving, and it is enough for a reader to fork the repository without inheriting a web stack I do not want to maintain as a solo author.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dependencies, downloads, and the social contract of open weights
&lt;/h2&gt;

&lt;p&gt;I rely on publicly available weights. That choice carries a social contract: read the license, respect attribution, and do not pretend the model is neutral truth. I also accept the reality that first-time downloads can fail for reasons outside my code. I mention this because newcomers sometimes blame their own competence when the network hiccups during a Hugging Face download. If that happened to you while reproducing my PoC, retrying with a stable connection usually fixes it. If it persists, mirror the weights locally and point the configuration at your mirror. I did not bake a mirror into the repository because I did not want to privilege a single hosting strategy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Narrative distance from production
&lt;/h2&gt;

&lt;p&gt;I keep repeating that this is experimental because I want the distance to be obvious. Production systems have change control, incident response, and accountability chains I am not simulating. When I say staleness_flag, I am not claiming an operational incident ticket exists. I am labeling a branch in my code. That distinction matters if someone reads this article quickly and assumes they can paste the repository into a live environment without additional work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What I learned about my own habits
&lt;/h3&gt;

&lt;p&gt;I noticed that I reached for matplotlib faster than I reached for unit tests in the first week. That is not a brag. It is a confession. Charts feel like progress. Tests feel like discipline. In a longer project I would add tests around the synonym expansion function and the merge path because those are the places where silent bugs hide. For this PoC, I relied on manual inspection and repeated runs. I am documenting that choice because I want readers who clone the repository to know where rigor ends and storytelling begins in my own process.&lt;/p&gt;

&lt;h3&gt;
  
  
  A word on naming
&lt;/h3&gt;

&lt;p&gt;I named the repository GuestResilience-HotelContext-AI because I wanted the words to sound operational without sounding like a product SKU. Names matter when you revisit a folder six months later. I have abandoned enough cleverly named experiments to appreciate boring clarity.&lt;/p&gt;

&lt;p&gt;I started this experiment because I wanted a personal answer to a simple question: what does resilience mean when the model is small, the corpus is synthetic, and the user language is sloppy? My answer, for now, is that resilience looks like measurement first, bounded repairs second, and honest failure labels third. I do not think that answer is universal. I think it is a reasonable discipline for a PoC that might otherwise collapse into storytelling.&lt;/p&gt;

&lt;p&gt;If I revisit the project, the first upgrade I would consider is a principled evaluation split: hold out chunks, paraphrase questions, and quantify how often expansion helps versus hurts. The second upgrade would be a real staleness pipeline, not a numeric field I typed by hand. The third upgrade would be an explicit separation between guest-facing summarization and evidence retrieval, even if both use models, because commingling them erodes auditability.&lt;/p&gt;

&lt;p&gt;If nothing else, I hope this write-up convinces you that resilience can be practiced as a discipline even when the dataset is synthetic. The point is not to win a leaderboard. The point is to build a habit of measuring, repairing, and labeling failure without embarrassment.&lt;/p&gt;

&lt;p&gt;I also want to leave you with a caution I apply to my own demos. Hospitality language intersects with accessibility, safety, and fairness. A retrieval stack that looks clever on a developer laptop can still be wrong in ways that matter. I wrote this as an experimental article precisely because I want room to be humble about those limits.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Tags: python, rag, machinelearning, hospitality&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Thank you for reading this far. I know it is a long piece. I wrote it at this length because I wanted the reasoning trail to be inspectable, not because I enjoy typing for its own sake.&lt;/p&gt;

&lt;p&gt;Disclaimer&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>python</category>
      <category>rag</category>
      <category>machinelearning</category>
      <category>hospitality</category>
    </item>
    <item>
      <title>Layered Agentic Retrieval for Retail Floor Questions: A Solo PoC</title>
      <dc:creator>Aniket Hingane</dc:creator>
      <pubDate>Sat, 04 Apr 2026 02:32:49 +0000</pubDate>
      <link>https://dev.to/exploredataaiml/layered-agentic-retrieval-for-retail-floor-questions-a-solo-poc-221g</link>
      <guid>https://dev.to/exploredataaiml/layered-agentic-retrieval-for-retail-floor-questions-a-solo-poc-221g</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fonlhrmxvfa2w3s37iv6f.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fonlhrmxvfa2w3s37iv6f.gif" alt="Cover animation" width="880" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How I Routed Associate Questions Across Specialized TF-IDF Indexes Before Assembly&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fona0gdwdbdmvnm9nc4x9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fona0gdwdbdmvnm9nc4x9.png" alt="Title diagram" width="800" height="495"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;This write-up documents a personal experiment I ran while thinking about how retail associates actually use knowledge in the moment. A shopper rarely asks a question that fits neatly into a single policy PDF. The phrasing is noisy, the intent is mixed, and the clock is always ticking. I built a small Python system that treats each question as a routing problem rather than a retrieval problem with a single index. Three independent TF-IDF corpora stand in for returns policies, product care guidance, and service-floor procedures. An orchestrator scores each domain, retrieves top hits from the winner, and optionally blends in a second domain when the primary score looks weak. I kept the entire pipeline on-device without calling a hosted language model, because I wanted the evidence to be inspectable and reproducible on a laptop. The repository is public for learning purposes only; it is not a product recommendation, not a deployment blueprint, and not connected to anything I have shipped at a job. I am describing it as a proof of concept because that is what it is, and I am careful not to claim that this small corpus behaves like a real enterprise knowledge base. If you only take one sentence away, take this: I cared more about inspectable routing than about impressing anyone with model names, and that priority shaped every file I wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Before I describe the code, I want to anchor the emotional posture of this article. I am writing as an individual who builds experiments in public to learn, not as someone who is reporting on a deployed system or claiming validated business outcomes. That framing matters because retail is easy to romanticize and easy to misrepresent. I have spent a fair amount of time watching how people ask questions in retail settings, not as a researcher with formal instruments but as someone who pays attention to phrasing when I am in line or when I am helping a friend think through a store policy. The questions are rarely perfect. Someone might say “I need to return this” while also mentioning “the coating on the jacket feels wrong after one wash.” That sentence mixes two worlds. One world is about returns and receipts. The other is about care instructions and product durability. If you flatten everything into one retrieval index, you can still get plausible text, but you lose the ability to explain why a snippet was chosen. That loss of explainability matters to me as an engineer, not because I dislike neural models, but because I like to know which shelf the system reached for first.&lt;/p&gt;

&lt;p&gt;In my PoC I chose a retail floor framing because it is relatable, easy to illustrate with synthetic documents, and avoids domains I have been avoiding in this personal writing series. I am not describing inventory optimization, warehouse counts, or pricing strategy here. I am not touching financial advice. I am staying with a narrow slice: how an associate-facing assistant might assemble evidence before anyone writes a customer-facing sentence. I also want to be clear about the social layer. Associates are not robots. They use judgment. A system that pretends to replace that judgment with a single score is not something I would defend. What I am experimenting with is a structured scaffold that keeps evidence visible so a human can still override.&lt;/p&gt;

&lt;p&gt;I wrote this article because I wanted a serious project that still fits on a laptop. I have seen enough demos where a model produces fluent language and hides the underlying evidence. I wanted the opposite. I wanted logs that read like engineering notes, not marketing copy. The code is structured so that I can print a bundle of evidence rows with identifiers and cosine scores. That is not glamorous, but it is the kind of transparency I find useful when I iterate.&lt;/p&gt;

&lt;p&gt;There is another motivation I should state plainly. I am interested in practices that survive contact with messy language. People shorten words, omit nouns, and rely on context. They say “the thirty-day thing” instead of “the return window policy.” Any retrieval system that assumes the query already contains canonical terms will fail in ways that look embarrassing on a demo but painful in real life. I did not solve that fully here. I only created a place to talk about it honestly while still writing code.&lt;/p&gt;

&lt;p&gt;I also want readers to know the scope boundary I used while writing. This article discusses a synthetic dataset and illustrative scoring rules. It does not describe any real retailer’s policies, staffing model, or vendor contracts. If a phrase resembles language you have seen in the wild, that is because operational writing converges on similar vocabulary, not because I copied private material.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's This Article About?
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The article walks through RetailFloor-AgenticRouter-AI, a Python project that ingests short customer-style questions, scores three separate TF-IDF indexes, and retrieves evidence from the best-matching domains before optional blending.&lt;/li&gt;
&lt;li&gt;I explain why I combined lexical hints with cosine similarity rather than relying on either signal alone in isolation.&lt;/li&gt;
&lt;li&gt;I show how the batch table and matplotlib chart help me see whether the demo is skewing toward one domain because of a bug or because of the wording of the synthetic questions.&lt;/li&gt;
&lt;li&gt;I discuss limitations honestly: tiny corpora, linear scoring, and heuristic thresholds are not the same as a live knowledge system.&lt;/li&gt;
&lt;li&gt;I include a code walkthrough that mirrors how I read the repository myself when I return to it after a gap.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;p&gt;The implementation is intentionally boring in a good way. I rely on Python 3.10 or newer, NumPy, scikit-learn for TF-IDF and cosine similarity, matplotlib for a bar chart, and Rich for readable terminal output. There is no hosted vector database and no cloud requirement; the entire index fits in memory.&lt;/p&gt;

&lt;p&gt;From where I stand, that stack is enough to demonstrate the idea that “agentic routing” in the small can be practiced with classical IR tooling when the corpus is tiny and the goal is structured assembly rather than open-ended generation. If I later swap TF-IDF for embeddings, the per-domain interfaces remain stable, which was a design goal while I sketched the modules.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpg3y2jcjlxy1v5ye7l8p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpg3y2jcjlxy1v5ye7l8p.png" alt="System architecture" width="800" height="279"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Read It?
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;If you are evaluating how to structure prompts or pre-model logic for operational assistants, this article offers a concrete pattern: treat context as composable blocks with clear boundaries.&lt;/li&gt;
&lt;li&gt;If you are learning scikit-learn’s text pipelines, the retrieval module is short and testable.&lt;/li&gt;
&lt;li&gt;If you care about reproducibility, the orchestrator gives you a baseline against which any future learned model can be compared.&lt;/li&gt;
&lt;li&gt;I think the read is most useful for practitioners who want a middle ground between “pure LLM” and “pure rules,” because the code shows exactly where those worlds meet in my PoC.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is also a pedagogical angle I care about. Many tutorials jump straight to embeddings and vector databases without establishing why lexical baselines still matter. I am not anti-embedding; I use them elsewhere. But I believe beginners should see cosine similarity on explicit vectors at least once, because it demystifies what “nearest neighbor” means in code rather than in marketing language.&lt;/p&gt;

&lt;p&gt;Finally, if you maintain open-source examples, you know the burden of dependencies. I kept the stack small so a reader in a constrained environment can still run the demo. That constraint shaped decisions as much as any architectural principle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Design
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Framing the problem without overfitting the story
&lt;/h3&gt;

&lt;p&gt;Before touching code, I spent time writing short synthetic shopper questions on paper. I noticed recurring patterns: some messages emphasize receipts and timing early, others bury the actionable detail in the second half, and a few mix care language with service language. I did not try to split multi-intent questions into multiple tickets in this repository. Instead, I focused on a single-text input so the routing stays easy to reason about. That choice trades realism for clarity, and I am comfortable stating that upfront.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why multiple indexes instead of one concatenated corpus
&lt;/h3&gt;

&lt;p&gt;The design starts from a simple observation I kept returning to while prototyping: returns policy text is not interchangeable with product care guidance. They answer different questions. Policies talk about windows, receipts, and eligibility. Care guidance talks about fabric, detergents, and storage. Service-floor procedures talk about pickup, escalations, and documented steps for adjustments. When I mixed those prematurely, I got tangled retrieval results. When I separated them, I could log each domain independently.&lt;/p&gt;

&lt;p&gt;The retrieval layer builds one TF-IDF vectorizer per domain. Each domain has a handful of short documents with identifiers. For each query, I compute a cosine similarity between the query vector and every document vector in that domain, and I take the maximum as a domain strength signal. That is a simple baseline, but it is a baseline I can explain to a colleague without drawing diagrams.&lt;/p&gt;

&lt;p&gt;The orchestration layer combines those strengths with a small lexical hint. The hint is a deliberately limited set of regular expressions that look for words like “return,” “wash,” or “curbside.” I did not try to build a full intent model. I wanted a small nudge that prevents absurd routing when the vector space is sparse. In my opinion, that is a trade you can criticize. I would rather hear that criticism than pretend the vector space is larger than it is.&lt;/p&gt;

&lt;h3&gt;
  
  
  The agentic step as a confidence gate
&lt;/h3&gt;

&lt;p&gt;I use the word “agentic” in a narrow sense. There is no autonomous loop that calls external tools. I am referring to a decision step that can widen retrieval when the primary domain looks weak. If the combined score for the primary domain falls below a threshold I tuned by hand, I pull additional hits from the next-best domain. That is not deep reasoning. It is a guardrail. I still call it agentic in the sense that the system chooses a second retrieval path based on measured confidence rather than a fixed pipeline.&lt;/p&gt;

&lt;p&gt;If I were to extend this experiment, I would log the threshold crossings and measure how often the secondary blend helps. In this PoC, I only observe the behavior in the console.&lt;/p&gt;

&lt;h3&gt;
  
  
  Retrieval choices and what I rejected
&lt;/h3&gt;

&lt;p&gt;I considered a few alternatives before settling on TF-IDF for the first public cut. A dense embedding model would likely rank semantically related chunks more robustly, but it would also introduce versioning questions, dependency weight, and reproducibility concerns for readers who just want to clone and run. I decided that demonstrating clean interfaces mattered more than squeezing extra retrieval quality from a miniature corpus.&lt;/p&gt;

&lt;p&gt;I also thought about BM25. It is a strong baseline for lexical tasks and behaves well on short documents. I stayed with TF-IDF largely because the scikit-learn pipeline is familiar to many readers and the difference between BM25 and TF-IDF on a handful of short documents is unlikely to change the story materially. If I expand the corpus by an order of magnitude, BM25 or a hybrid approach becomes more compelling.&lt;/p&gt;

&lt;h3&gt;
  
  
  Observability as a first-class requirement
&lt;/h3&gt;

&lt;p&gt;I log the evidence bundle for the first query in every run not because the first query is special, but because it proves the pipeline without drowning the reader in repetition. In a longer study I would probably log structured JSON for every query and ship it to a file, but the PoC keeps stdout readable.&lt;/p&gt;

&lt;p&gt;The matplotlib chart is part of the same philosophy. A batch table tells you what happened row by row; a distribution tells you whether the demo batch skewed toward one domain. In my experiments, skew often revealed mistakes in keyword priorities rather than retrieval mistakes, which surprised me at first.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqi4b1re7c7v48r0kciij.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqi4b1re7c7v48r0kciij.png" alt="Runtime sequence" width="752" height="443"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Get Cooking
&lt;/h2&gt;

&lt;p&gt;The entry point is &lt;code&gt;main.py&lt;/code&gt;. It keeps the demo batch in one helper so the narrative stays obvious when someone reads top to bottom.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_demo_queries&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;(id, short label, customer text)&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Q-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;returns window&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I bought jeans last week, tags still on, can I still bring them back with my receipt?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Q-02&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;care&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;How should I wash this water-resistant jacket without ruining the coating?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="c1"&gt;# ... additional synthetic questions ...
&lt;/span&gt;    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this does: it defines the synthetic workload as tuples that include a question identifier, a human-readable label for my own notes, and the free-text body. I structured it this way because separating labels from the text lets me test routing without fabricating metadata inside the prose.&lt;/p&gt;

&lt;p&gt;Why I wrote it this way: early on, I inlined labels as hashtags inside the text and immediately regretted it. Parsing labels from natural language is a separate project. For this PoC, explicit fields keep runs reproducible.&lt;/p&gt;

&lt;p&gt;The orchestration layer is &lt;code&gt;run_agentic_retrieval&lt;/code&gt; in &lt;code&gt;orchestrator.py&lt;/code&gt;. It ranks domains, retrieves primary hits, and optionally blends secondary hits.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_agentic_retrieval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;indexes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;IntentDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DomainIndex&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;top_k_per_domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;OrchestratorResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rank_domains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indexes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;primary_domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;primary_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;secondary_domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secondary_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;IntentDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RetrievalHit&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="n"&gt;primary_hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;retrieve_top_k&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indexes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;primary_domain&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;top_k_per_domain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;primary_hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;primary_domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;rationale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Primary domain &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;primary_domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(combined routing score=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;primary_score&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;).&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;primary_score&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;_SECONDARY_THRESHOLD&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;secondary_score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;secondary_hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;retrieve_top_k&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indexes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;secondary_domain&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;top_k_per_domain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;secondary_hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;secondary_domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;rationale&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; Secondary blend &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;secondary_domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(combined=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;secondary_score&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;) because primary score stayed below &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;_SECONDARY_THRESHOLD&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;OrchestratorResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;primary_domain&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;primary_domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;primary_score&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;primary_score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;secondary_domain&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;secondary_domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;secondary_score&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;secondary_score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;rationale&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;rationale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;OrchestratorResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;primary_domain&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;primary_domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;primary_score&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;primary_score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;secondary_domain&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;secondary_score&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;rationale&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;rationale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this does: it materializes the agentic gate in code. If the primary score is below threshold, I append evidence from the runner-up domain. If not, I keep the evidence narrow.&lt;/p&gt;

&lt;p&gt;Why I wrote it this way: I wanted the branching logic to be explicit and readable. A hidden implicit merge would have made debugging harder when I tuned thresholds.&lt;/p&gt;

&lt;p&gt;The hybrid score itself is computed in &lt;code&gt;rank_domains&lt;/code&gt; by combining cosine strength with lexical boosts. I kept the boosts small so they cannot dominate the vector signal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_combined_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indexes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;IntentDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DomainIndex&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IntentDomain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;domain_strength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indexes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_lexical_boost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this does: it anchors the routing in measurable similarity while still allowing short, common retail words to nudge the domain when the corpus is tiny.&lt;/p&gt;

&lt;p&gt;Per-domain retrieval is standard TF-IDF with cosine similarity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;retrieve_top_k&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DomainIndex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;RetrievalHit&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;qv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vectorizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;sims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cosine_similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;doc_matrix&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ravel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argsort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;sims&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;RetrievalHit&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
        &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;RetrievalHit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)]),&lt;/span&gt;
                &lt;span class="n"&gt;snippet&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this does: it returns the top-k most similar documents within a single domain index. That is the building block the orchestrator repeats.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxm7v251i5uhhoic2sjj6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxm7v251i5uhhoic2sjj6.png" alt="End-to-end workflow" width="524" height="819"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Setup
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Clone the public repository from GitHub: &lt;code&gt;https://github.com/aniket-work/RetailFloor-AgenticRouter-AI&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Create a virtual environment inside the project directory using &lt;code&gt;python -m venv venv&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Activate the environment using &lt;code&gt;source venv/bin/activate&lt;/code&gt; on macOS or Linux&lt;/li&gt;
&lt;li&gt;Install dependencies with &lt;code&gt;pip install -r requirements.txt&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step-by-step details can be found at the repository README in the same project. I prefer local virtual environments because they keep experiments isolated and reproducible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Run
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Run &lt;code&gt;python main.py&lt;/code&gt; from the repository root with the virtual environment active.&lt;/li&gt;
&lt;li&gt;Read the first evidence bundle, which prints the primary domain and the retrieved snippets with scores.&lt;/li&gt;
&lt;li&gt;Read the batch summary table and open &lt;code&gt;output/domain_distribution.png&lt;/code&gt; to see the matplotlib distribution.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Theory and personal notes on similarity in tiny corpora
&lt;/h2&gt;

&lt;p&gt;When I describe TF-IDF to someone who has only used embeddings, I often start with frequency rather than geometry. Term frequency inside a document tells you what the document emphasizes. Inverse document frequency down-weights terms that appear everywhere. Once you have a sparse vector per document and a vector for the query, cosine similarity becomes a concrete operation: a dot product normalized by magnitudes. That story is not new. I still find it valuable because it connects the math to the words people actually typed.&lt;/p&gt;

&lt;p&gt;In my experiments with this PoC, the corpus is so small that IDF behaves differently than it would on a large intranet crawl. Rare words can dominate. Common words can look overly important if they appear in multiple documents within the same domain. I noticed that when I added a few more sentences to one policy snippet, the relative rankings shifted more than I expected. That sensitivity is not a secret flaw; it is a reminder that retrieval quality tracks corpus curation.&lt;/p&gt;

&lt;p&gt;I also thought about correlation between domains. In a single merged index, a query might retrieve a returns snippet and a care snippet together because both mention “original packaging” or similar phrasing. By splitting indexes, I force the system to decide which domain is primary before I show mixed evidence. That decision can be wrong, but at least it is explicit. In my opinion, explicit wrongness is easier to debug than implicit blending.&lt;/p&gt;

&lt;p&gt;Another topic I want to address is calibration. Cosine scores on TF-IDF are not probabilities. I still print them because relative ranking matters more than absolute numbers for this demo. If I ever publish a more serious evaluation, I would separate “routing accuracy” from “snippet usefulness” and measure them with different labeled sets. For now, I rely on a batch table and a chart, which is humble instrumentation, but it matches the scale of the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deeper walkthrough of the batch questions I chose
&lt;/h2&gt;

&lt;p&gt;I picked six questions because they cover different shapes of language without pretending to be a comprehensive benchmark. The returns window question is direct. The care question uses product language. The curbside question pulls service-floor vocabulary. The “ambiguous” running shoe question is intentionally written to stress the boundary between care guidance and subjective comfort language. The gift receipt question tests whether returns language routes cleanly. The escalation question tests service-floor procedures.&lt;/p&gt;

&lt;p&gt;When I first ran the batch, I looked at the primary domain column before I looked at scores. That habit comes from older debugging practice: identify the categorical outcome, then inspect the numeric confidence. In my PoC, I also read the chart to see whether one domain swallowed the batch. If that happened without a good reason, I assumed I had a bug or a badly worded question. That is a cheap sanity check, but it caught a few mistakes while I iterated.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would measure next if I invested another weekend
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Precision and recall for domain routing against a labeled set of at least two hundred queries.&lt;/li&gt;
&lt;li&gt;Mean reciprocal rank for the top evidence snippet within the correct domain.&lt;/li&gt;
&lt;li&gt;Frequency of secondary-blend activations and whether those cases correlate with improved human ratings.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I have not done that work here. I am stating it as a matter of intellectual honesty. A chart in a blog post is not an evaluation suite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure modes I observed while iterating
&lt;/h2&gt;

&lt;p&gt;Sometimes the vector score for the primary domain is modest even when the lexical hint is strong. In those cases, the combined score still tends to land on the right domain because the hint is doing real work. The opposite also happened during early drafts: strong cosine matches to the wrong domain because of shared words like “store” or “order.” I addressed some of that by tightening documents so repeated generic words appear less often, but the real fix for a production setting would be more documents and better tokenization choices, not cleverer regex.&lt;/p&gt;

&lt;p&gt;I also saw cases where the secondary blend did not trigger because the primary score crossed the threshold even though a human would still want cross-domain evidence. That is a design tension. If I lower the threshold, I blend more often and risk noisy evidence. If I raise the threshold, I stay pure but miss helpful cross-domain context. I do not think there is a universal answer. I think there is only a policy choice that should be explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I avoided a hosted model in the core loop
&lt;/h2&gt;

&lt;p&gt;I am not opposed to models. I use them in other projects. In this PoC, I wanted the repository to remain lightweight and the behavior to remain inspectable for readers who may not have API keys or budget. I also wanted to avoid a moving target. Hosted models change versions; retrieval baselines change less often. For a learning artifact, stability matters.&lt;/p&gt;

&lt;p&gt;If I integrated a model later, I would still keep the routing structure. The orchestrator pattern is not tied to TF-IDF. It is a way to decide which evidence shelves to open. In my view, that separation ages well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Statistics and visualization as a discipline habit
&lt;/h2&gt;

&lt;p&gt;The matplotlib chart is simple: counts of primary domain picks across the batch. I still find it useful because it forces a second perspective on the same data. Tables can hide imbalance when you are focused on individual rows. A bar chart makes imbalance obvious. As per my experience writing operational tooling, that kind of redundancy is how I catch mistakes before they become narratives.&lt;/p&gt;

&lt;p&gt;I also think visualization discipline matters when writing publicly. A demo can look convincing because the author cherry-picked queries. A batch section with a chart is still cherry-picked, but it is harder to hide systemic skew without looking inconsistent. I am not claiming purity. I am claiming a slightly higher bar than a single happy-path screenshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this relates to my broader experiments with context assembly
&lt;/h2&gt;

&lt;p&gt;Across several personal PoCs, I keep returning to the same lesson: the model is only as grounded as the evidence you hand it. Routing is one way to ground. Chunking is another. Metadata filters are another. In this retail framing, routing is the headline because it is the piece that most directly mirrors how I think a careful associate works. I picture someone mentally classifying the question before reaching for a binder or a search box. The code is a crude metaphor for that mental step.&lt;/p&gt;

&lt;p&gt;I also think solo experiments have a hidden advantage. There is no committee to smooth the awkward edges. If a design choice is hard to explain, I feel it immediately because I have to write the article myself. That pressure improves clarity even when it does not improve novelty. In my opinion, many good engineering blogs come from that kind of forced explanation rather than from raw brilliance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data hygiene notes for anyone who forks the repository
&lt;/h2&gt;

&lt;p&gt;If you fork this project and replace the synthetic corpus with your own text, start with document boundaries. Decide what constitutes a chunk. Decide whether headings belong in the chunk or in metadata. Decide whether you need stable identifiers for compliance reasons. I used short stable identifiers like &lt;code&gt;ret_001&lt;/code&gt; because they read well in logs. In a real setting, you might need provenance fields and timestamps. None of that appears here because I am not simulating compliance tooling.&lt;/p&gt;

&lt;p&gt;I would also caution against mixing marketing language with policy language in the same chunk unless you intend to. Marketing copy often uses emotional words that pollute retrieval for operational questions. In my synthetic set, I tried to keep tone dry and procedural. That choice is artificial, but it is intentionally artificial.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security and privacy framing
&lt;/h2&gt;

&lt;p&gt;This PoC does not store customer data, payment data, or loyalty identifiers. I mention loyalty only inside synthetic policy text as a generic procedural note. If you adapt the idea to real systems, you should treat evidence logs as sensitive depending on your environment. Retrieval indexes can leak information through side channels if someone can probe them repeatedly. I am not providing a threat model here. I am naming the concern because responsible write-ups should name concerns even when the demo is synthetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Longer reflection on agentic retrieval as a phrase
&lt;/h2&gt;

&lt;p&gt;Language shifts quickly in this field. I use “agentic” because the orchestrator makes a conditional decision that changes which retrievals run. That is a narrow meaning. I am not claiming autonomous agency, persistent memory, or tool use beyond vector retrieval. If the word feels too flashy for your taste, you can substitute “conditional multi-retrieval” and the code still reads the same.&lt;/p&gt;

&lt;p&gt;From my perspective, the value of the word is that it signals intent to practitioners who are comparing patterns. If you are building a catalog of approaches, you want names that map to behaviors. The behavior here is: measure confidence, branch, merge evidence. That is enough to distinguish the flow from a single-query single-index baseline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expanded discussion of matplotlib output and how I read it
&lt;/h2&gt;

&lt;p&gt;The chart file is written to &lt;code&gt;output/domain_distribution.png&lt;/code&gt;. When I open it, I look for dominance first. If one bar towers over the others, I ask whether the batch questions were written to favor that domain accidentally. Then I look at the absolute counts. With six questions, ties and singletons are common. That is fine for a story, but it would be insufficient for a statistical claim. I treat the chart as a sanity check, not as proof of generalization.&lt;/p&gt;

&lt;p&gt;I also think about color and accessibility. The default color cycle in matplotlib is familiar to many readers, but it is not perfect for every color vision profile. If I extended this project with a public UI, I would revisit palette choices. For a static PNG in a repository, I kept the defaults to reduce dependencies and keep the focus on structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed between iterations while writing this article
&lt;/h2&gt;

&lt;p&gt;Early drafts used only cosine similarity without lexical hints. The routing looked mathematically pure but sometimes felt silly in plain language. I added small boosts because I wanted the demo to track common sense when the corpus is tiny. Some readers will dislike that because it introduces hand-tuned rules. I accept the criticism. I would rather show a transparent hand-tuned rule than hide the same bias inside an unlabeled embedding space.&lt;/p&gt;

&lt;p&gt;I also adjusted the secondary threshold upward once I saw how often the blend triggered. The goal was to make blending meaningful rather than routine. If blending happens on every query, it stops being a guardrail and becomes a second retrieval path you always pay for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What I learned about thresholds
&lt;/h3&gt;

&lt;p&gt;I spent more time than I expected tuning the secondary threshold and lexical boosts. That is typical for small corpora. When you only have a few documents per domain, cosine similarity can swing based on a single shared word. I do not consider that a flaw in cosine similarity. I consider it a reminder that the corpus is the real product.&lt;/p&gt;

&lt;h3&gt;
  
  
  Edge cases I still think about
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Multi-intent questions that require two different operational actions, not just two evidence bundles.&lt;/li&gt;
&lt;li&gt;Questions that reference SKU numbers or store-specific hours that are not in the synthetic corpus.&lt;/li&gt;
&lt;li&gt;Situations where policy language conflicts across documents, which this PoC does not attempt to resolve.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Ethics and responsible framing
&lt;/h3&gt;

&lt;p&gt;I believe any assistant that touches customer-facing work should default to transparency. That means citing sources, showing scores, and making it obvious when the system is uncertain. I did not build a customer-facing UI here, but I did build the kind of evidence rows I would want to see before trusting a draft answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Roadmap if this stays a hobby experiment
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Replace TF-IDF with BM25 or embeddings when the corpus grows.&lt;/li&gt;
&lt;li&gt;Add evaluation harnesses with labeled queries rather than eyeballing batch tables.&lt;/li&gt;
&lt;li&gt;Add structured logging to JSON for offline analysis.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  A few more words on reproducibility and environment capture
&lt;/h3&gt;

&lt;p&gt;Whenever I publish a PoC, I ask myself whether someone can reproduce the same numbers on their machine. For this project, the deterministic parts are the TF-IDF fit and the query order. The parts that can drift are library versions and floating point noise. I pinned versions loosely in &lt;code&gt;requirements.txt&lt;/code&gt; with minimum versions rather than exact hashes because this is not a safety-critical artifact. If I needed bitwise reproducibility, I would pin exact versions and record a seed wherever randomness appears. Randomness does not play a role in the current retrieval path, which keeps the story simpler.&lt;/p&gt;

&lt;p&gt;I also think about documentation as part of reproducibility. A repository without a clear run command is a puzzle, not an experiment. That is why I kept &lt;code&gt;main.py&lt;/code&gt; as a single entry point and why I describe the output paths explicitly. From my experience, the fastest way to lose a reader is to hide the command they should run after cloning.&lt;/p&gt;

&lt;h3&gt;
  
  
  Narrative closure without overstating the result
&lt;/h3&gt;

&lt;p&gt;I want to end the technical portion with a calm statement of scope. This PoC demonstrates routing and retrieval assembly. It does not demonstrate customer satisfaction. It does not demonstrate associate productivity. It does not demonstrate compliance alignment. Those outcomes require measurements I did not perform. I am naming the gap on purpose because overstated claims are how experimental articles age poorly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Repository link
&lt;/h3&gt;

&lt;p&gt;Public code for this experiment: &lt;code&gt;https://github.com/aniket-work/RetailFloor-AgenticRouter-AI&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;I keep this repository separate from my publishing scripts so the public repo only contains the PoC implementation, diagrams, and images. If you clone it, you will not find my article drafts or automation utilities mixed into the same tree, because I want the repository to stay a clean reference implementation for the idea itself.&lt;/p&gt;

&lt;p&gt;Disclaimer&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Tags: python, retail, machinelearning, agents&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>retail</category>
      <category>machinelearning</category>
      <category>agents</category>
    </item>
    <item>
      <title>Layered Context Routing for Campus Operations: A Facilities Intake PoC</title>
      <dc:creator>Aniket Hingane</dc:creator>
      <pubDate>Fri, 03 Apr 2026 03:19:04 +0000</pubDate>
      <link>https://dev.to/exploredataaiml/layered-context-routing-for-campus-operations-a-facilities-intake-poc-41b2</link>
      <guid>https://dev.to/exploredataaiml/layered-context-routing-for-campus-operations-a-facilities-intake-poc-41b2</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftsi2dgt9viyyrw3oa6is.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftsi2dgt9viyyrw3oa6is.gif" alt="Cover animation" width="880" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How I Stacked Policy, Place, and Urgency Signals to Route Maintenance Requests&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3c4m68pixj55r5i00d7x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3c4m68pixj55r5i00d7x.png" alt="Layered context concept" width="800" height="454"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;This write-up describes a personal experiment where I treat campus facilities intake as a context engineering problem rather than a single prompt. I combine TF-IDF retrieval over a small policy corpus with building metadata and lightweight urgency hints parsed from free text, then route tickets with explicit rules so every decision stays inspectable. The code lives in a public repository I published for learning purposes, and nothing here should be read as production guidance for a real university or as anything connected to an employer. From my perspective, the lesson worth sharing is that when operational language is messy, stacking context in named layers makes debugging and iteration far easier than stuffing everything into one opaque blob.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;I have spent a fair amount of time thinking about how large language models behave when the input is short, ambiguous, and emotionally loaded. Facilities tickets are a good toy domain for that reason. A message might mention a fume hood, a basketball practice schedule, and a broken card reader in adjacent sentences. If you send that straight into a generic completion call, you can get fluent text that is wrong in subtle ways. As per my experience, the failure mode is rarely “the model cannot write sentences.” It is usually “the model does not know which institutional rule actually applies,” or “the model over-trusts the most recent sentence.”&lt;/p&gt;

&lt;p&gt;In my experiments, I wanted a system that still fits on a laptop, does not require a proprietary dataset, and makes the reasoning chain visible in ordinary logs. I chose a campus operations framing because it forces a blend of safety language, building-specific nuance, and time-of-day common sense without touching regulated domains I am intentionally avoiding in this series. The repository is a solo sketch, not a deployed service, and I refer to it throughout as a proof of concept.&lt;/p&gt;

&lt;p&gt;There is another motivation I should state plainly. I am interested in practices that survive contact with maintenance engineers, students, and staff who do not care about the underlying ML buzzwords. People submit tickets under stress. They shorten building names, omit room numbers, and reference “that hallway near the lab” without GPS coordinates. Any system that pretends the text is already structured is going to fail in ways that look embarrassing on a demo but painful in real life. I did not solve that fully here; I only created a place to talk about it honestly while still writing code.&lt;/p&gt;

&lt;p&gt;I also want readers to know the scope boundary I used while writing. This article discusses a synthetic dataset and illustrative SLAs. It does not describe any real institution’s priorities, staffing model, or vendor contracts. If a phrase resembles language you have seen in the wild, that is because operational writing converges on similar vocabulary, not because I copied private material.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's This Article About?
&lt;/h2&gt;

&lt;p&gt;The article walks through the design of CampusContextRouter-AI, a Python project that ingests synthetic maintenance-style requests, retrieves relevant policy snippets, attaches place context from a JSON registry, derives urgency signals from the wording, and emits a route bucket with a priority band and a notional SLA window. I wrote it this way because I wanted to mirror how a human dispatcher glances at policy, then place, then severity, before choosing a queue.&lt;/p&gt;

&lt;p&gt;You will see how I separate retrieval from routing, why I kept the router deterministic in this iteration, and how I generate both a Rich table for the terminal and a simple matplotlib chart so a batch run has a visual artifact. I also discuss limitations honestly: tiny corpora, linear scoring, and heuristic SLAs are not the same as a live work-order system.&lt;/p&gt;

&lt;p&gt;If you are wondering what “context engineering” means in concrete terms here, my working definition is simple: decide what information belongs together, decide what must never be mixed, and serialize the result in a predictable shape. Retrieval produces evidence. Place metadata grounds the evidence. Session signals modulate urgency. Routing consumes all three without collapsing them into an undifferentiated string. That definition may differ from how other authors use the phrase, and that is fine; the implementation is the ground truth for this PoC.&lt;/p&gt;

&lt;p&gt;You should also expect commentary on failure modes. A demo that only shows happy paths is a brochure, not engineering writing. I call out retrieval sparsity, policy conflict, and the limits of regex urgency. I discuss what I would measure next if this stayed a hobby project for more than a few weekends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;p&gt;The implementation is intentionally boring in a good way. I rely on Python 3.10 or newer, NumPy, scikit-learn for TF-IDF and cosine similarity, matplotlib for a bar chart, and Rich for readable terminal output. There is no hosted vector database and no cloud requirement; the entire index fits in memory.&lt;/p&gt;

&lt;p&gt;From where I stand, that stack is enough to demonstrate the idea that “context engineering” can be practiced with classic IR tooling when your corpus is small and your goal is structured assembly rather than open-ended generation. If I later swap TF-IDF for embeddings, the layered interfaces remain stable, which was a design goal while I sketched the modules.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8vnm04k2ggmc1fbkgw85.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8vnm04k2ggmc1fbkgw85.png" alt="System architecture" width="800" height="175"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Read It?
&lt;/h2&gt;

&lt;p&gt;If you are evaluating how to structure prompts or pre-model logic for operational chatbots, this article offers a concrete pattern: treat context as composable blocks with clear boundaries. If you are learning scikit-learn’s text pipelines, the retrieval module is short and testable. If you care about reproducibility, the deterministic router gives you a baseline against which any future learned model can be compared.&lt;/p&gt;

&lt;p&gt;I think the read is most useful for practitioners who want a middle ground between “pure LLM” and “pure rules,” because the code shows exactly where those worlds meet in my PoC.&lt;/p&gt;

&lt;p&gt;There is also a pedagogical angle I care about. Many tutorials jump straight to embeddings and vector databases without establishing why lexical baselines still matter. I am not anti-embedding; I use them elsewhere. But I believe beginners should see cosine similarity on explicit vectors at least once, because it demystifies what “nearest neighbor” means in code rather than in marketing language.&lt;/p&gt;

&lt;p&gt;Finally, if you maintain open-source examples, you know the burden of dependencies. I kept the stack small so a reader in a constrained environment can still run the demo. That constraint shaped decisions as much as any architectural principle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Design
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Framing the problem without overfitting the story
&lt;/h3&gt;

&lt;p&gt;Before touching code, I spent time writing short synthetic tickets on paper. I noticed recurring patterns: some messages emphasize harm or hazard words early, others bury the actionable detail in the second half, and a few mix multiple issues that would normally be split in a mature work-order system. I did not try to solve splitting in this repository. Instead, I focused on a single-text input so the context layers stay easy to reason about. That choice trades realism for clarity, and I am comfortable stating that upfront.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why layers instead of one concatenated prompt
&lt;/h3&gt;

&lt;p&gt;The design starts from a simple observation I kept returning to while prototyping: policy text is not interchangeable with place metadata. Policies answer what must happen in general. Place metadata answers where the work lives and what constraints repeat for that site. Session signals answer how hot the ticket sounds and whether the clock matters. When I mixed those prematurely, I got tangled prompts. When I separated them, I could log each layer independently.&lt;/p&gt;

&lt;p&gt;The retrieval layer reads &lt;code&gt;data/policies.json&lt;/code&gt;. Each record is a chunk with an identifier, a topic tag, and prose. The TF-IDF vectorizer uses English stop words and unigrams plus bigrams to catch phrases like “fume hood” that unigrams alone might dilute. For each ticket, I take the top few chunks by cosine similarity and format them as a bullet list with scores.&lt;/p&gt;

&lt;p&gt;The place layer reads &lt;code&gt;data/buildings.json&lt;/code&gt;. Each building has a code, human-readable name, zone label, hours profile, and short risk notes. I do not attempt geospatial reasoning in this PoC; the point is to show how a second JSON source can be merged without contaminating the policy text.&lt;/p&gt;

&lt;p&gt;The signal layer currently combines a local hour and weekday flag with an urgency score derived from regular-expression keyword groups on the ticket text. The score is deliberately primitive. In a later experiment I might replace it with a lightweight classifier, but I wanted something explainable first.&lt;/p&gt;

&lt;p&gt;Routing maps the assembled layers to an enumerated bucket such as laboratory safety, classroom AV, grounds, HVAC, or a general bucket. Priorities and SLA hours are assigned with transparent rules that look at both the keyword path and the urgency score. That logic lives entirely in Python so I can unit test it without GPU dependencies.&lt;/p&gt;

&lt;p&gt;Architecturally, the flow is linear: load JSON, fit the TF-IDF index once, iterate demo tickets, assemble layers per ticket, call the router, collect rows, render a Rich table, and plot bucket counts. The diagrams in the repository restate the same story visually.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcyquhmpkhajoy2ct7x76.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcyquhmpkhajoy2ct7x76.png" alt="Runtime sequence" width="800" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Retrieval choices and what I rejected
&lt;/h3&gt;

&lt;p&gt;I considered a few alternatives before settling on TF-IDF for the first public cut. A dense embedding model would likely rank semantically related chunks more robustly, but it would also introduce versioning questions, dependency weight, and reproducibility concerns for readers who just want to clone and run. I decided that demonstrating clean interfaces mattered more than squeezing extra retrieval quality from a miniature corpus. In my opinion, that is a trade only the author can judge; for teaching purposes, I wanted the smallest artifact that still supports cosine similarity and top-k inspection.&lt;/p&gt;

&lt;p&gt;I also thought about BM25. It is a strong baseline for lexical tasks and behaves well on short documents. I stayed with TF-IDF largely because the scikit-learn pipeline is familiar to many readers and the difference between BM25 and TF-IDF on eight short policies is unlikely to change the story materially. If I expand the corpus by an order of magnitude, BM25 or a hybrid approach becomes more compelling.&lt;/p&gt;

&lt;h3&gt;
  
  
  Urgency scoring as a deliberately imperfect heuristic
&lt;/h3&gt;

&lt;p&gt;The urgency score is built from weighted regular expressions. That looks naive, and it is naive. I still found value in it because it forces me to name the cues I care about: leaks, odors, elevators, HVAC loss, outdoor lighting, and a handful of AV terms. Each cue adds a partial weight capped at one. The cap matters; without it, a long message with many benign keywords could look hotter than a short emergency note.&lt;/p&gt;

&lt;p&gt;When I tested early versions, I saw false positives where “water” appeared in a benign sentence. I tightened patterns to word boundaries and preferred compound cues. This is not a claim that regex is sufficient in production. It is a claim that explainable baselines are useful when you compare future models against something you can read in a single screen.&lt;/p&gt;

&lt;h3&gt;
  
  
  Observability as a first-class requirement
&lt;/h3&gt;

&lt;p&gt;I log the layered context for the first ticket in every run not because the first ticket is special, but because it proves the pipeline without drowning the reader in repetition. In a longer study I would probably log structured JSON for every ticket and ship it to a file, but the PoC keeps stdout readable.&lt;/p&gt;

&lt;p&gt;The matplotlib chart is part of the same philosophy. A batch table tells you what happened row by row; a distribution tells you whether the demo batch skewed toward one bucket. In my experiments, skew often revealed mistakes in keyword priorities rather than retrieval mistakes, which surprised me at first.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8jbbv1lohqbe4wxqqf6s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8jbbv1lohqbe4wxqqf6s.png" alt="End-to-end workflow" width="492" height="822"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Get Cooking
&lt;/h2&gt;

&lt;p&gt;The entry point is &lt;code&gt;main.py&lt;/code&gt;. It keeps the demo batch in one helper so the narrative stays obvious when someone reads top to bottom.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_demo_tickets&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;(id, building_code, text, hour_local, weekday)&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;T-1001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SCI-E&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Strong chemical odor near fume hood B2; two students reported eye irritation.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="c1"&gt;# ... additional synthetic tickets ...
&lt;/span&gt;    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this does:&lt;/strong&gt; It defines the synthetic workload as tuples that include a ticket identifier, a building code that keys into &lt;code&gt;buildings.json&lt;/code&gt;, the free-text body, and a synthetic clock. I structured it this way because separating clock information from the text lets me test urgency scoring and time-aware policies independently without fabricating timestamps inside the prose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I wrote it this way:&lt;/strong&gt; Early on, I inlined timestamps as strings inside the ticket text and immediately regretted it. Parsing times from natural language is a separate project. For this PoC, explicit fields keep runs reproducible.&lt;/p&gt;

&lt;p&gt;The layered assembly happens through &lt;code&gt;assemble_layers&lt;/code&gt; in &lt;code&gt;context_layers.py&lt;/code&gt;. The function pulls retrieval results, formats three blocks, and returns a &lt;code&gt;LayeredContext&lt;/code&gt; dataclass.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;assemble_layers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;query_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;building_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SessionSignals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PolicyIndex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;buildings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BuildingContext&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;LayeredContext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;retrieved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;policy_block&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_format_policy_block&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retrieved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;retrieved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;policy_block&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POLICY SNIPPETS: (no retrieval match; use baseline routing rules)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;buildings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;building_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;building_code&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;place_block&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_format_place_block&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;signal_block&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_format_signal_block&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;LayeredContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;policy_block&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;policy_block&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;place_block&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;place_block&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;signal_block&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;signal_block&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;retrieved&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;retrieved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this does:&lt;/strong&gt; It centralizes formatting so the router always sees the same headings for each layer. Empty retrieval is handled with a explicit fallback string rather than silent failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I structured it this way:&lt;/strong&gt; In my opinion, the hardest part of small retrieval systems is debugging silent degradation. If nothing matches, I want that fact visible in the console output.&lt;/p&gt;

&lt;p&gt;Retrieval itself is a thin wrapper around scikit-learn.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PolicyIndex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;In-memory TF-IDF index over policy text.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;PolicyChunk&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;
        &lt;span class="n"&gt;corpus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_vectorizer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TfidfVectorizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;lowercase&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;stop_words&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;english&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ngram_range&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;min_df&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_matrix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_vectorizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fit_transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;corpus&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;RetrievedChunk&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_vectorizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;sims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cosine_similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_matrix&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;flatten&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argsort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;sims&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;RetrievedChunk&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RetrievedChunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_chunks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this does:&lt;/strong&gt; It builds a matrix once and scores incoming ticket text as another vector. Cosine similarity ranks chunks, and I discard zero scores to avoid clutter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I learned:&lt;/strong&gt; On a toy corpus, bigrams matter. Without them, “fume hood” sometimes loses to generic maintenance words. I kept the corpus tiny on purpose to force myself to think about chunk wording.&lt;/p&gt;

&lt;p&gt;The router combines keyword cues, the top retrieved topic, and session urgency.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decide_route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;free_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;layered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LayeredContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;building&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BuildingContext&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SessionSignals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;RoutingDecision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;topic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_topic_from_retrieval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;layered&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;keyword_bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_adjust_bucket_from_keywords&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;free_text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RouteBucket&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;keyword_bucket&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;keyword_bucket&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;topic&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;laboratory&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;RouteBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SAFETY_EHS&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;topic&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;classroom_av&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;RouteBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AV&lt;/span&gt;
    &lt;span class="c1"&gt;# ... additional topic mappings ...
&lt;/span&gt;    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;RouteBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GENERAL&lt;/span&gt;

    &lt;span class="n"&gt;urgency_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;urgency_hint_score&lt;/span&gt;
    &lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;P2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;sla&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;48.0&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RouteBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SAFETY_EHS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RouteBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PLUMBING&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;urgency_score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;P0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;sla&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;4.0&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;RouteBucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ACCESS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;P1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;sla&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;8.0&lt;/span&gt;
    &lt;span class="c1"&gt;# ... additional escalations ...
&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;RoutingDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;sla_hours&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sla&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;rationale&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rationale_parts&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this does:&lt;/strong&gt; It makes the decision path explicit. Keyword overrides fire first because certain phrases imply a channel regardless of retrieval noise. Topic labels from the best chunk act as a secondary signal. SLA tightening uses both bucket membership and the urgency score.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I put it this way:&lt;/strong&gt; I needed a single function I could read during demos without opening a notebook. The rationale string is there so future me remembers why a ticket landed where it did.&lt;/p&gt;

&lt;p&gt;Finally, plotting is one function that turns bucket names into counts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;plot_bucket_distribution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;out_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;counts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;labels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;fig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subplots&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;figsize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;4.5&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;color&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#2c5282&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Synthetic routing batch: bucket counts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_ylabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Tickets&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ax&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tick_params&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rotation&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;fig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tight_layout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;out_path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exist_ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;fig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;savefig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dpi&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;plt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this does:&lt;/strong&gt; It produces a basic bar chart so the batch run is not only textual. For the animated cover asset, I used that chart as the UI half of the GIF after the terminal sequence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repository link:&lt;/strong&gt; The full project, including diagrams and the terminal animation asset, is available at &lt;a href="https://github.com/aniket-work/CampusContextRouter-AI" rel="noopener noreferrer"&gt;https://github.com/aniket-work/CampusContextRouter-AI&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Reporting code stays intentionally thin: tables for people, files for artifacts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;print_routing_table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;console&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Campus facilities intake (batch routing)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ticket&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cyan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;no_wrap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Building&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;magenta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Route&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;green&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Pri&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;justify&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;center&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SLA h&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;justify&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;right&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ticket_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;building&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sla&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;building&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sla&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this does:&lt;/strong&gt; It renders aligned columns with consistent headers so a batch run looks like a dispatch screen rather than a raw log dump.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I structured it this way:&lt;/strong&gt; In my opinion, presentation quality changes how seriously I take my own outputs during development. If the table looks sloppy, I assume the logic is sloppy.&lt;/p&gt;

&lt;p&gt;The urgency helper is small but central to how priorities tighten.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_URGENCY_WORDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\b(leak|flooding|flood|spill|water)\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.35&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\b(smoke|fire|odor|fume|chemical)\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.45&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\b(elevator|stuck|door won\'t open|door wont open)\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\b(no heat|no ac|freezing|overheat)\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\b(projector|microphone|av|audio|display)\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\b(light|outage|dark|walkway)\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_urgency_hint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_URGENCY_WORDS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this does:&lt;/strong&gt; It scans for cues that should raise urgency regardless of which policy chunk wins retrieval.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I learned:&lt;/strong&gt; Weight tuning is subjective. I chose weights that made the science-lab odor scenario land in a high band without pushing every AV ticket into emergency territory.&lt;/p&gt;

&lt;h3&gt;
  
  
  How this differs from “just prompt better”
&lt;/h3&gt;

&lt;p&gt;It is tempting to believe a single system message can replace structured preprocessing. Sometimes that works for short tasks. For operational intake, my experience has been that models benefit from retrieval that is inspectable outside the model. I am not arguing against LLMs; I am arguing that the PoC should show where the boundaries belong. If the retrieval list is wrong, I can fix the corpus or the vectorizer without touching the router. If the router rules are wrong, I can adjust routing without touching retrieval. That separation of concerns saved me time during debugging.&lt;/p&gt;

&lt;h3&gt;
  
  
  What a language model could do in a later iteration
&lt;/h3&gt;

&lt;p&gt;If I add a model, I would keep it as a rewriter or validator, not as the sole authority. A plausible pattern is: assemble layers exactly as today, ask the model to propose a bucket and rationale, then compare against deterministic rules. Disagreements become training data or prompts for refinement. I have not implemented that here because I wanted the repository to remain runnable without API keys, but the layering is compatible with that roadmap.&lt;/p&gt;

&lt;h3&gt;
  
  
  Performance characteristics I measured informally
&lt;/h3&gt;

&lt;p&gt;This is not a benchmark article, but I did sanity-check runtime on a laptop. Fitting the TF-IDF matrix on eight policies is effectively instantaneous. Routing five tickets is trivial. Matplotlib dominates wall time relative to retrieval, which reinforces that the PoC is not CPU-bound. If I scaled to thousands of policies, I would need a more serious index and probably batch vectorization, but that is not the bottleneck today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Setup
&lt;/h2&gt;

&lt;p&gt;Step-by-step details can be found in the repository README. At a high level, the setup I used while iterating locally follows a predictable pattern.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create an isolated virtual environment in the project directory so dependencies never leak across unrelated experiments.&lt;/li&gt;
&lt;li&gt;Install requirements from &lt;code&gt;requirements.txt&lt;/code&gt; exactly as pinned there to avoid surprise upgrades to scikit-learn behavior.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;python main.py&lt;/code&gt; with the bundled demo batch to confirm retrieval, routing, and chart generation all succeed on your machine.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you clone the repository, you will notice there is no &lt;code&gt;.env&lt;/code&gt; requirement for the baseline demo. I kept secrets out of the PoC on purpose so CI or readers can execute it without API keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Run
&lt;/h2&gt;

&lt;p&gt;When I run &lt;code&gt;python main.py&lt;/code&gt;, the program prints the full layered context for the first ticket, then prints the batch table, then writes &lt;code&gt;output/routing_bucket_distribution.png&lt;/code&gt;. That order is intentional: the first block proves the retrieval and formatting pipeline, the table proves routing consistency, and the chart proves that visualization hooks stay wired.&lt;/p&gt;

&lt;p&gt;In my observation, the most interesting console output is the policy snippet list with scores. Even with eight policies, you can watch how sensitive the ranking is to verbs like “odor” versus “noise.” That sensitivity informed how I wrote the synthetic tickets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge Cases I Thought About
&lt;/h2&gt;

&lt;p&gt;No PoC is complete without acknowledging where it would break. These points are worth spelling out because they shaped what I did not attempt.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Sparse retrieval:&lt;/strong&gt; If the ticket uses slang that never appears in the policy corpus, TF-IDF may return low scores across the board. The router still runs, but the topic signal becomes weak. A mitigation I considered is hybrid retrieval with a keyword inverted index, which would be a natural extension.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Conflicting policies:&lt;/strong&gt; Real campuses can have overlapping rules. I store independent chunks and do not yet model precedence. In a future iteration, explicit precedence edges between chunk IDs would be cleaner than hoping retrieval ranks resolve conflicts.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Time semantics:&lt;/strong&gt; I pass hour and weekday as integers rather than parsing from text. That avoids accidental contradictions between embedded timestamps and structured fields.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Equity and access:&lt;/strong&gt; Routing touches accessibility topics. I include them because ignoring them would be unrealistic, but the SLA numbers are illustrative only. A production system would need institutional review, not a hobby script.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Duplicate submissions:&lt;/strong&gt; Real users open multiple tickets for the same incident. I do not deduplicate or thread conversations in this repository. A deduplication layer would likely sit upstream of retrieval, comparing embeddings of entire messages and linking to an incident ID.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Seasonality:&lt;/strong&gt; A field house ticket in January is not the same as one in August. My building metadata includes a seasonal hours profile label, but I do not dynamically adjust SLAs by season. Extending the signal layer with calendar metadata would be straightforward, but it would also require more realistic data than I wanted to maintain for a hobby repo.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Language and tone:&lt;/strong&gt; The PoC assumes English prose of moderate formality. Multilingual campuses would need tokenization choices and policy corpora per language. I did not attempt multilingual retrieval because verifying quality would require skills and resources outside the scope of a solo weekend project.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Malicious input:&lt;/strong&gt; Free-text fields can be abused. I do not implement content filtering here. If this were more than a local script, I would add rate limits, length limits, and basic abuse detection before any retrieval occurs.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Ethics and Responsibility
&lt;/h2&gt;

&lt;p&gt;Even though this repository is synthetic, the language of safety and access deserves care. I wrote the tickets and policies to resemble realistic operational phrases without copying any private incident text. From my perspective, that distinction matters: public demos should never repurpose confidential work orders.&lt;/p&gt;

&lt;p&gt;I also want to be explicit that automated routing for physical risk scenarios should never be the only line of defense. The PoC emits labels; it does not dispatch tradespeople, text students, or close tickets. Treat it as a learning scaffold.&lt;/p&gt;

&lt;h2&gt;
  
  
  Future Roadmap (Personal Experiments Only)
&lt;/h2&gt;

&lt;p&gt;If I revisit this repository, several extensions seem worthwhile, still on my own time and still labeled experimental.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Replace TF-IDF with embeddings while keeping the same layer boundaries, then measure how often the router disagrees with the baseline.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Add a second corpus for local procedures that updates more frequently than policy, mimicking how some campuses separate “policy” from “playbook.”&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Introduce evaluation harnesses with labeled tickets, even if synthetically expanded, so precision and recall become measurable instead of eyeballed.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Wrap the router output as JSON for a tiny local web UI if I want a friendlier demo than the terminal alone.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Add property tests that generate random word order permutations for tickets to see whether retrieval remains stable enough for my tolerance thresholds.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Explore calibration for urgency scoring so numeric outputs map to observed human labels in a small user study. That is far beyond this PoC, but worth naming as a scientific next step rather than an engineering tweak.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of those items are promises; they are directions I might explore when curiosity and spare time align.&lt;/p&gt;

&lt;h2&gt;
  
  
  Documentation habits that helped me
&lt;/h2&gt;

&lt;p&gt;While building this, I noticed that my velocity correlated with how aggressively I documented assumptions in the README. Not tutorial prose for beginners, but crisp statements of non-goals. When I wrote “illustrative SLA,” I stopped myself from secretly believing the numbers meant more than they did. When I listed repository layout, I caught a path mistake before publishing.&lt;/p&gt;

&lt;p&gt;I also kept commit messages boring on purpose. Small repositories deserve readable history too. If I ever return to this code months later, I want future me to recognize intent without decoding clever commit titles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproducibility notes
&lt;/h2&gt;

&lt;p&gt;Reproducibility is part engineering and part discipline. Pinning dependencies avoids the subtle drift that happens when scikit-learn changes defaults across versions. Keeping random seeds matters when you add stochastic components; this PoC has none, which is a feature for now. Recording the Python version in the README is a small touch that prevents “works on my machine” surprises.&lt;/p&gt;

&lt;p&gt;If you fork the repository, consider writing down your own environmental constraints. I develop on macOS, but the code should run anywhere Python runs. If matplotlib backend issues appear on a headless server, switching to a non-interactive backend is a known fix; I did not need it for local runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  A longer note on evaluation and what I did not measure
&lt;/h2&gt;

&lt;p&gt;Evaluation is where hobby projects either mature or remain toys. In this PoC, I relied on manual inspection: reading retrieved snippets, checking whether the science lab odor ticket escalated appropriately, and scanning the distribution chart for obvious skew. That approach is acceptable for a first public version because the goal was architectural clarity, not leaderboard scores.&lt;/p&gt;

&lt;p&gt;If I were to formalize evaluation without access to private tickets, I would start by synthesizing a larger labeled set from templates. I would vary lexical overlap, negation, and multi-issue messages. I would measure top-k recall for policy topics and confusion matrices for route buckets. I would also track stability: if I paraphrase a ticket without changing meaning, does retrieval remain broadly consistent? That kind of robustness test often reveals brittleness faster than aggregate accuracy.&lt;/p&gt;

&lt;p&gt;I did not build those harnesses here because they expand repository scope quickly. Test data management is a project of its own. Still, naming the gap matters. Readers should not mistake a crisp table output for validated operational performance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Visual assets and why the GIF exists
&lt;/h2&gt;

&lt;p&gt;The repository includes Mermaid diagrams rendered to PNG via mermaid.ink because I wanted graphics that match the tone of technical documentation rather than stock photography. The animated GIF pairs a terminal sequence with a matplotlib chart to mirror how I actually work: run a script, scan the table, glance at a plot. Creating the GIF took extra time, but from my perspective it communicates intent faster than static screenshots alone.&lt;/p&gt;

&lt;p&gt;I followed a strict palette conversion pipeline for the GIF to reduce flicker on platforms that are picky about animated assets. The details are mundane image processing, but the outcome matters when you publish where rendering quirks exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Personal lessons I did not expect
&lt;/h2&gt;

&lt;p&gt;While wiring the urgency heuristic, I expected retrieval to dominate mistakes. Instead, I often found myself adjusting keyword lists because the language of urgency in synthetic tickets did not match the language of policy chunks. That mismatch reminded me that retrieval and rules interact; you cannot tune one in isolation forever.&lt;/p&gt;

&lt;p&gt;Another surprise was how quickly the Rich table made the PoC feel “real.” Presentation is not substance, but human perception matters when you judge your own progress. I kept the table formatting minimal on purpose. Dense color in terminal output ages poorly and distracts from the routing story.&lt;/p&gt;

&lt;p&gt;Finally, I was reminded how much I enjoy small JSON corpora. They are easy to diff in code review, easy to version, and easy to explain to someone unfamiliar with machine learning. If I had started with a database, I would have spent more time on migrations than on routing logic.&lt;/p&gt;

&lt;p&gt;If you made it this far, thank you for reading carefully. I wrote this piece to document not only what the code does, but why I accepted certain limitations while rejecting shortcuts that would have made the demo flashier yet less honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;What I take away from this experiment is that “context engineering” is not only a prompt-design exercise. It is also an exercise in deciding what information deserves its own channel, how much structure to add before model calls, and how to leave an audit trail. The campus framing helped me keep those questions grounded.&lt;/p&gt;

&lt;p&gt;If you try the code, I hope you modify the policy JSON and watch how retrieval shifts. In my experience, that kind of hands-on perturbation teaches more than reading another listicle about embeddings.&lt;/p&gt;

&lt;p&gt;I also keep returning to a humbling point: good operations depend on people who answer phones, visit sites, and coordinate trades. Software can sort and summarize, but it cannot replace the embodied knowledge of how a specific building behaves in winter. This PoC stays modest because that human layer matters more than any script I would ship on a weekend.&lt;/p&gt;

&lt;p&gt;As a final note, this article is an experimental write-up based on a hobby repository. It is not production guidance, not campus policy, and not affiliated with any organization I work with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclaimer
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Tags: python, context, machinelearning, campus&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>context</category>
      <category>machinelearning</category>
      <category>campus</category>
    </item>
    <item>
      <title>ShootMesh-AI: A Transparent “Production Office” for Staged Film-and-TV Days</title>
      <dc:creator>Aniket Hingane</dc:creator>
      <pubDate>Thu, 02 Apr 2026 02:21:04 +0000</pubDate>
      <link>https://dev.to/exploredataaiml/shootmesh-ai-a-transparent-production-office-for-staged-film-and-tv-days-131a</link>
      <guid>https://dev.to/exploredataaiml/shootmesh-ai-a-transparent-production-office-for-staged-film-and-tv-days-131a</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2l0vcf6e4cbyrpmfirf1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2l0vcf6e4cbyrpmfirf1.png" alt="Title" width="325" height="886"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How I modeled department proposals, a merge policy, and an audit ledger without hiding the coordination rules&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I built a small Python proof of concept called ShootMesh-AI that behaves like a miniature production office for a synthetic shooting day. Separate modules pretend to be scheduling, locations, safety, and equipment voices. Each one proposes actions when a staged incident appears. A coordinator applies a deterministic merge policy with an explicit priority order, and a ledger records what was chosen and how many minutes the plan slipped. The runnable code prints an ASCII table to the terminal and writes two charts to disk. From my perspective, the interesting part is not cleverness for its own sake; it is inspectability. I can read the policy, replay the ledger, and explain why a particular department “won” a merge at a particular step. The repository is public for learning, and the article is written as a personal experiment rather than as on-set guidance or employer-backed work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;I have spent a fair amount of time thinking about how software can support coordination without pretending to be a substitute for human judgment. That tension shows up everywhere, but it is especially easy to picture on a location day where weather, permits, noise, gear faults, and human delays interact. I am not claiming that a script can run a set. What I am claiming, from my own tinkering, is that a disciplined simulation can help clarify what “coordination logic” even means when multiple subsystems disagree.&lt;/p&gt;

&lt;p&gt;In my experiments, I wanted a structure that felt like a company in miniature: different functions propose, a single merge point decides, and a ledger preserves accountability. I wrote this article because I wanted to document the design choices I made while building ShootMesh-AI, the trade-offs I accepted, and the ways I would extend the idea if I kept pushing on it. I put it this way because I value narratives that show the reasoning path, not only the final file tree.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's This Article About?
&lt;/h2&gt;

&lt;p&gt;This article walks through an experimental architecture for multi-agent style coordination applied to a film and television production day scenario. The agents are not large language models in this repository. They are code modules with narrow responsibilities. That was intentional. I chose determinism so that two runs with the same code path produce the same ledger, which makes the merge policy testable without stochastic noise.&lt;/p&gt;

&lt;p&gt;The storyline of the PoC is simple. A list of synthetic incidents arrives in order. For each incident, every department contributes one or more proposals. Each proposal carries a priority class, an estimated minute shift, and a confidence score used only for tie breaking under fixed rules. The coordinator selects a winner, records the outcome, and moves on. After the last incident, the program summarizes how often each department’s proposal won and plots cumulative slippage across the day.&lt;/p&gt;

&lt;p&gt;I also discuss what this miniature setup suggests about real coordination systems: transparency, traceability, and the difference between a policy you can cite and a model output you can only guess about. As per my experience, no collaborative group is involved here; this is a solo build and a solo narrative.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;p&gt;I kept the stack boring on purpose. The project is pure Python with &lt;code&gt;matplotlib&lt;/code&gt; for charts and the standard library everywhere else. There is no database, no message broker, and no web server. I made that choice so the article could focus on coordination mechanics rather than infrastructure.&lt;/p&gt;

&lt;p&gt;From where I stand, a small PoC should compile quickly in the reader’s mind. If I had introduced a queueing system and a container orchestrator, I would have spent more time explaining operations than explaining coordination. I may revisit richer infrastructure later, but this repository stays intentionally compact.&lt;/p&gt;

&lt;p&gt;The diagrams in the article were rendered from Mermaid definitions into PNG images using the mermaid.ink approach, then checked into the repository so the README and the article can reference stable URLs. The animated GIF follows a terminal-first layout and then transitions into a simple bar chart; I generated it locally with Pillow using a single global color palette to avoid flicker on platforms that are picky about GIF encoding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Read It?
&lt;/h2&gt;

&lt;p&gt;If you are evaluating how to structure agent-like systems, you might be weighing opaque end-to-end models against explicit policies. This write-up shows a middle path: small agents propose, but the merge policy remains readable code. I think that pattern matters for debugging and for stakeholder trust, even in an experimental setting.&lt;/p&gt;

&lt;p&gt;You might also read it if you want a concrete Python layout that separates types, scenario data, agents, coordination, reporting, and plotting. I separated those concerns because I did not want a single “god file” to become the hiding place for assumptions. In my opinion, the discipline of folders is part of the message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Design
&lt;/h2&gt;

&lt;p&gt;I started by naming the boundaries. A production office is not one brain; it is a set of recurring conversations. Scheduling optimizes order and protects certain blocks. Locations negotiates physical reality and alternates. Safety refuses shortcuts when the refusal is non-negotiable. Equipment translates technical faults into workable mitigations. I did not try to encode every craft; I chose four to keep the PoC legible.&lt;/p&gt;

&lt;p&gt;The coordinator encodes a value ordering that I can defend in prose: safety first, then schedule integrity, then creative accommodations, then cost-sensitive mitigations. That ordering is not universal. It is a hypothesis I used to make the demo behave sensibly when proposals conflict. The important part, in my view, is that the ordering lives in one place and can be changed without rewiring the entire codebase.&lt;/p&gt;

&lt;p&gt;I also wanted a ledger that reads like an audit trail. Each step stores the incident text, the winning department label, and the minutes shifted by the chosen proposal. If I later attach identifiers for scenes or setups, the ledger format still works. I wrote the ledger as a list of structured entries rather than as unstructured log lines to keep downstream analysis simple.&lt;/p&gt;

&lt;p&gt;The visuals mirror the architecture. The title diagram emphasizes the coordinator and ledger as the spine, with department agents feeding proposals inward. The architecture diagram compresses the runtime pipeline to a short chain: incidents, parallel proposals, priority sort, ledger append, artifacts. The sequence diagram is intentionally modest; it exists to show message-like flow even though everything runs in-process. The flow diagram captures the loop and the termination condition.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fricytpwx9fbuvspxxugo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fricytpwx9fbuvspxxugo.png" alt="Architecture overview" width="800" height="63"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flpls1dpjolvbw6bdh2ob.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flpls1dpjolvbw6bdh2ob.png" alt="Agent messaging shape" width="800" height="349"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Faj3z1d8noxulsb8psl97.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Faj3z1d8noxulsb8psl97.png" alt="Process flow" width="444" height="712"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Get Cooking
&lt;/h2&gt;

&lt;p&gt;The runnable project lives here: &lt;code&gt;https://github.com/aniket-work/ShootMesh-AI&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Below I split the code at meaningful boundaries and explain what each block is doing in the narrative voice I used while building it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Types and explicit priorities
&lt;/h3&gt;

&lt;p&gt;I defined priorities as an enumeration and kept proposals immutable. That choice reduces the chance that a later step mutates a proposal accidentally. It also makes the merge logic easier to read because the priority tier is a first-class field rather than a buried string constant.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Priority&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;SAFETY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;safety&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;SCHEDULE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schedule&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;CREATIVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;creative&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;COST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cost&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Proposal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Priority&lt;/span&gt;
    &lt;span class="n"&gt;minutes_shift&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this does:&lt;/strong&gt; It establishes the vocabulary for proposals and forces every proposal to carry the fields the coordinator expects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I structured it this way:&lt;/strong&gt; I wanted the merge layer to depend on stable shapes. Frozen dataclasses made that contract obvious while I iterated on agent behaviors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I learned:&lt;/strong&gt; When I first sketched the PoC, I used loose dictionaries. The code worked, but errors showed up late. Moving to typed objects caught mistakes earlier and made the article easier to write because the data model became self-documenting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Coordinator merge policy
&lt;/h3&gt;

&lt;p&gt;The coordinator ranks proposals using a tuple key that reflects my stated ordering. Safety should dominate when it appears. Within a tier, higher confidence wins. When confidence ties, the policy prefers smaller minute shifts because I wanted the demo to bias toward less disruptive fixes when all else is equal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;PRIORITY_ORDER&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Priority&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Priority&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SAFETY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Priority&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SCHEDULE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Priority&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CREATIVE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Priority&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;choose_proposal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proposals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Proposal&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Proposal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;proposals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no proposals&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sort_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Proposal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PRIORITY_ORDER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;minutes_shift&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proposals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sort_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;rationale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;selected &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;) &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;with confidence &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alternatives considered: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rationale&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this does:&lt;/strong&gt; It selects a single winning proposal and constructs a short human-readable rationale string for the ledger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I structured it this way:&lt;/strong&gt; I deliberately avoided machine learning here. The point of the PoC is to show a policy you can print on a whiteboard. If I had hidden the ranking inside a model, I would have undermined the story about transparency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I learned:&lt;/strong&gt; Tie-breaking rules are never neutral. I had to document why minute shifts mattered to me as a tie breaker. Without that narrative, readers might assume the numbers were arbitrary when they were a deliberate preference for smaller disruptions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Department agents with narrow expertise
&lt;/h3&gt;

&lt;p&gt;Each agent subclass proposes only when it has something credible to say. For example, safety proposes hard stops for weather and gear faults, while scheduling proposes resequencing when talent is late. I avoided giving safety a generic fallback proposal on every incident because it caused safety to over-win merges in early versions of the PoC. That was a useful bug because it reminded me that “always-on” agents can drown out specialized voices if I am not careful.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SchedulingAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DepartmentAgent&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scheduling&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;propose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;incident&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Incident&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Proposal&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;incident&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;IncidentKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TALENT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="nc"&gt;Proposal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Slide block 2 after lunch; protect golden hour exterior&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;Priority&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SCHEDULE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;minutes_shift&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.82&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="nc"&gt;Proposal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Re-sequence setups to absorb slip without dropping pages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;Priority&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SCHEDULE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;minutes_shift&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.68&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this does:&lt;/strong&gt; It returns one or more proposals for a given incident, scoped to scheduling’s perspective.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I structured it this way:&lt;/strong&gt; I wanted the agents to feel like departments with partial visibility, not omniscient oracles. In a real system, each proposal might be backed by data from call sheets, travel estimates, or scout photos. Here, the proposals are illustrative, but the structure is what I expect to reuse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I learned:&lt;/strong&gt; The difference between a demo and a toy is whether the extension points are honest. I left room to replace proposal text with retrieved facts later without rewriting the coordinator.&lt;/p&gt;

&lt;h3&gt;
  
  
  Engine and run summary
&lt;/h3&gt;

&lt;p&gt;The engine loops incidents, gathers proposals, calls the coordinator, and accumulates ledger entries. It also computes a simple winner share per department label by counting wins and normalizing to one. That statistic is what feeds the bar chart.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_production_day&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;RunSummary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;seed&lt;/span&gt;
    &lt;span class="n"&gt;incidents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;default_day_incidents&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;agents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;all_department_agents&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;LedgerEntry&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;agents&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;slippage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;incident&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;incidents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;proposals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;collect_proposals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;incident&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;incident&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proposals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;slippage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resulting_shift_min&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chosen_from&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chosen_from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;

    &lt;span class="n"&gt;total_w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;
    &lt;span class="n"&gt;agent_weights&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;total_w&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;RunSummary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;total_incidents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;incidents&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;total_slippage_min&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slippage&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;agent_weights&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;agent_weights&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this does:&lt;/strong&gt; It executes the full synthetic day and returns both the ledger and aggregate metrics for plotting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I structured it this way:&lt;/strong&gt; I kept orchestration separate from proposal logic so I can test the coordinator against handcrafted proposal lists if I want targeted unit tests later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I learned:&lt;/strong&gt; Even a short loop benefits from clear naming. When I wrote the first draft, I inlined too much and struggled to explain it in prose. Pulling &lt;code&gt;collect_proposals&lt;/code&gt; and &lt;code&gt;run_step&lt;/code&gt; into helpers made the article easier to write and the code easier to read.&lt;/p&gt;

&lt;h3&gt;
  
  
  Entry point and artifacts
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;main.py&lt;/code&gt; script prints a banner, renders the ASCII table, and writes PNGs into &lt;code&gt;output/&lt;/code&gt;. I kept console output friendly because I knew the GIF would lean on the terminal aesthetic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_production_day&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ledger_to_rows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ascii_table&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;step&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;department&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shift_min&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;incident (truncated)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;plot_agent_influence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;agent_weights&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent_influence.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;plot_slippage_timeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resulting_shift_min&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ledger&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slippage_timeline.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this does:&lt;/strong&gt; It turns the run summary into human-readable terminal output and chart files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I structured it this way:&lt;/strong&gt; I wanted a single command that a reader could run after clone, with no configuration step. That lowers friction for experimentation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I learned:&lt;/strong&gt; ASCII tables photograph well in articles and GIFs. That sounds trivial, but visual continuity matters when you are trying to show a serious PoC rather than a loose notebook.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Setup
&lt;/h2&gt;

&lt;p&gt;Step by step details can be found at:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Clone the repository from &lt;code&gt;https://github.com/aniket-work/ShootMesh-AI.git&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Create a virtual environment in the repository root using &lt;code&gt;python3 -m venv venv&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Activate the environment using your platform’s standard activation command&lt;/li&gt;
&lt;li&gt;Install dependencies with &lt;code&gt;pip install -r requirements.txt&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;python main.py&lt;/code&gt; and confirm that &lt;code&gt;output/&lt;/code&gt; contains PNG charts&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I deliberately do not require API keys or cloud accounts for the demo. In my opinion, that constraint is a feature for readers who want to reproduce results on a laptop without standing up services.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let's Run
&lt;/h2&gt;

&lt;p&gt;When I run the program locally, I expect to see a table with five rows for the default scenario and a total slippage figure derived from the chosen proposals. I also expect the charts to reflect which departments won merges most often. In the version I pinned while writing, safety wins more frequently than others because the staged day includes several incidents where safety proposals legitimately sit at the top of the priority ordering. That outcome is not a claim about real-world frequency; it is a consequence of the synthetic mix I authored.&lt;/p&gt;

&lt;p&gt;If you compare the terminal output to the ledger logic, you should see a direct mapping. That traceability is the point. I am not asking the reader to trust a hidden score; I am asking the reader to inspect the policy and the ledger together.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvrzj77bth8or0vf5m2lh.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvrzj77bth8or0vf5m2lh.gif" alt="Cover animation" width="800" height="472"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge cases I considered while iterating
&lt;/h2&gt;

&lt;p&gt;Even a toy coordination loop has edge cases worth mentioning. Empty proposal lists are invalid for a decision step, so the coordinator raises rather than silently picking a winner. If I ever model a department outage, I need an explicit representation of “no proposal” rather than an empty list that crashes the merge. I would also add typed incident identifiers so repeated text does not accidentally collapse distinct events in analytics.&lt;/p&gt;

&lt;p&gt;Another edge case is conflicting notions of “confidence.” In this PoC, confidence is a scalar used for tie breaking within a priority tier. It is not calibrated probability. If I elevated this experiment, I would separate calibrated likelihoods from policy weights, and I would document the difference aggressively to avoid misinterpretation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deeper dive on policy design and alternatives
&lt;/h2&gt;

&lt;p&gt;When I began sketching ShootMesh-AI, I considered three broad approaches before settling on the explicit priority stack I implemented. The first approach was a single scoring function that combined every attribute into one number. It would have been compact, but it would also have hidden the moral and operational precedence that I wanted to discuss in plain language. The second approach was a rule engine with dozens of special cases. I rejected that for the PoC because it would have looked like accidental complexity rather than principled structure. The third approach, which I adopted, was a small ordered list of priority classes with deterministic tie breaks inside each class.&lt;/p&gt;

&lt;p&gt;From my perspective, the third approach mirrors how a production office often argues: first establish what is non-negotiable, then argue schedule integrity, then discuss creative compromises, and only then talk about money-saving shortcuts. Reality is messier than code, but the metaphor helped me keep the modules coherent. I also appreciated that readers could disagree with my ordering and still understand exactly what to change.&lt;/p&gt;

&lt;p&gt;I thought about adding dynamic weights that shift depending on the day’s risk profile. That would be interesting, but it would also introduce a second story about how those weights are chosen and audited. I decided that a static policy produced a cleaner narrative for a first public version. If I revisit the idea, I would likely expose weights as data loaded from a file with version metadata so runs can be tied to a named policy document.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observability, replay, and the value of a boring ledger
&lt;/h2&gt;

&lt;p&gt;One lesson I keep relearning in my experiments is that the most valuable artifact is often not the final chart but the sequence of decisions that produced it. The ledger in ShootMesh-AI is small, yet it already supports a basic kind of replay: you can read the incident text, see which department label won, and connect that to the minute shift recorded for the step. In a larger system, I would add correlation identifiers and timestamps, but I would still keep the ledger append-only in spirit.&lt;/p&gt;

&lt;p&gt;Replay matters because coordination bugs are often stories told incorrectly. If a plan looks wrong, you want to know whether the inputs were wrong, whether a proposal was malformed, or whether the merge policy did exactly what it promised and the surprise is simply an uncomfortable trade-off. In my opinion, separating those explanations is half the battle.&lt;/p&gt;

&lt;p&gt;I also considered emitting structured JSON alongside the ASCII table. I did not add it to the repository because I wanted to keep the surface area small, yet the design naturally extends in that direction. A JSON stream would make it easier to feed downstream dashboards without parsing terminal output, and it would still preserve transparency if the JSON schema is documented.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I deliberately did not build
&lt;/h2&gt;

&lt;p&gt;Scope discipline was important. I did not build a web UI, a mobile app, or a collaborative editor. I did not integrate calendars, maps, or real-time messaging. I did not connect to vendor APIs for equipment rental houses or location databases. None of those omissions reflect a belief that they are unimportant; they reflect a desire to keep the PoC intellectually honest about what is being validated.&lt;/p&gt;

&lt;p&gt;I also did not build a learned coordinator. There are fascinating research directions where a model learns to mimic human merge decisions from historical days. That could be powerful, but it would shift the article’s emphasis away from inspectability and toward data collection and evaluation methodology. I wanted the first public version to stand on deterministic logic so a reader could diff two commits and understand behavioral changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproducibility, randomness, and the seed parameter
&lt;/h2&gt;

&lt;p&gt;The engine accepts a seed argument for symmetry with common machine learning utilities, but the current scenario path is deterministic. I kept the parameter because I anticipate a future where proposals might be sampled or perturbed during stress tests. In the present code, the seed is a quiet placeholder. I am mentioning it explicitly so nobody assumes hidden randomness where none exists.&lt;/p&gt;

&lt;p&gt;When I add stochastic elements later, I would log the seed alongside the policy version in the ledger header. That pattern has served me well in other experiments because it closes the loop between “interesting run” and “parameters I can share.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Naming, mental models, and why “agent” is overloaded
&lt;/h2&gt;

&lt;p&gt;The word “agent” means too many things in 2026. In this repository, an agent is simply an object with a &lt;code&gt;propose&lt;/code&gt; method and a role label. It does not imply autonomy in the sense of long-lived goal pursuit. It does not imply a large language model. I picked the term because it matches the mental model of departments proposing alternatives.&lt;/p&gt;

&lt;p&gt;If I renamed everything to “module” or “function,” the architecture would look flatter and more boring, which might be technically accurate but less helpful for readers who think in terms of organizational roles. Language shapes how we reason about responsibility. I kept the agent language while trying not to mystify it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure modes that showed up in early drafts
&lt;/h2&gt;

&lt;p&gt;In an early draft, safety proposals appeared for every incident with a small minute shift. The merge policy correctly prioritized safety, which meant safety “won” too often and the chart looked unrealistic. I adjusted the safety agent so it only proposes when the incident category genuinely intersects safety-critical concerns. That change produced a more balanced distribution of winners while still respecting hard stops for weather, gear faults, and curfew-style constraints.&lt;/p&gt;

&lt;p&gt;Another failure mode was ambiguous tie breaks when two proposals shared priority and confidence. Sorting with &lt;code&gt;reverse=True&lt;/code&gt; on a tuple required careful attention to signs. I chose to prefer smaller minute shifts when higher-priority fields tie because that matched my narrative about minimizing disruption. If I inverted that sign by mistake, the demo still ran, but the behavior would silently favor larger slips. That is exactly the kind of bug a ledger helps catch: you notice unreasonable minute shifts and trace backward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance characteristics and why I barely mention them
&lt;/h2&gt;

&lt;p&gt;The PoC runs fast. There is no network I/O, no database, and no heavy numerical work beyond chart rendering. I mention performance not to boast but to set expectations: this article is about coordination clarity, not throughput engineering. If I scaled the idea to thousands of incidents per day with rich attachments, I would profile proposal generation and likely introduce caching for repeated subproblems.&lt;/p&gt;

&lt;p&gt;For now, the performance story is intentionally dull. Dull can be good when the goal is to focus attention on semantics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Broader parallels I see in other industries
&lt;/h2&gt;

&lt;p&gt;While I anchored the story in film and television production because it makes the role separation vivid, the same structural pattern appears whenever multiple specialists contribute competing recommendations under time pressure. In my observation, software architecture reviews, incident response rotations, and even large event logistics share a family resemblance: proposals, merges, and records.&lt;/p&gt;

&lt;p&gt;The ShootMesh-AI PoC is not a universal engine for those domains. The incident taxonomy is narrow, and the proposals are synthetic. Still, the scaffolding transfers: define proposals with explicit priority classes, centralize merge logic, and persist decisions in a ledger that humans can read without specialized tooling.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I would test this more seriously
&lt;/h2&gt;

&lt;p&gt;If I invest more time, I would add targeted unit tests for &lt;code&gt;choose_proposal&lt;/code&gt; with crafted lists that isolate tie breaks. I would add scenario tests that feed a full day and assert golden ledger outputs for a pinned policy version. I would also add a property test that randomly permutes proposal order within a step to verify the merge result is order-invariant aside from true ties.&lt;/p&gt;

&lt;p&gt;Testing proposals from each agent independently would be worthwhile too. That would require extracting sample incidents and expected proposal shapes into tables. The effort would pay off if the codebase grows more elaborate proposal generators.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on documentation and the README
&lt;/h2&gt;

&lt;p&gt;I wrote the README to stand alone for visitors who arrive from the repository before they read any article. That meant mirroring the diagrams, describing setup with exact commands, and stating ethical limits plainly. From my experience, open repositories benefit from a crisp boundary between “what this is” and “what this is not.” I tried to draw that boundary without sounding defensive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Personal reflection on solo work and scope
&lt;/h2&gt;

&lt;p&gt;I built this alone, and that choice shaped the trade-offs. A broader collaboration might have pushed for richer scenarios, more agents, or a polished UI sooner. Working solo let me control the narrative and keep the code small enough to explain in one article. In my view, that trade-off is acceptable for an experimental artifact meant to communicate an idea rather than ship a product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expanded commentary on charts and what they do not prove
&lt;/h2&gt;

&lt;p&gt;The bar chart of merge outcomes is descriptive, not normative. It answers a narrow question: under this synthetic scenario and this policy, which department labels tended to win. It does not say that safety should win that often in real life, and it does not estimate the quality of creative output. The cumulative slippage line is also a synthetic curve. It helps visualize how minute shifts stack across incidents, but it does not model parallel work streams or overlapping crews.&lt;/p&gt;

&lt;p&gt;I included the charts because the instructions I follow for my publishing experiments emphasize statistical summaries as a discipline. Even when the numbers are toy numbers, the practice of summarizing runs prevents a purely anecdotal story. I appreciate that constraint even when it adds work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing the loop with the public repository
&lt;/h2&gt;

&lt;p&gt;The code and images live in a public GitHub repository so you can verify claims directly. If a paragraph in this article ever drifts from the code, the repository becomes the source of truth. I prefer that arrangement over an article that only talks about code without pointing to a commit you can inspect.&lt;/p&gt;

&lt;p&gt;When you open the repository, start with &lt;code&gt;main.py&lt;/code&gt; if you want the fastest path to behavior, then read &lt;code&gt;coordinator.py&lt;/code&gt; if you want the merge policy in one screen, then skim &lt;code&gt;agents.py&lt;/code&gt; if you want to see how proposals vary by incident kind. That reading order mirrors how I explain the project verbally.&lt;/p&gt;

&lt;h2&gt;
  
  
  A longer look at why transparency beats mystery in coordination demos
&lt;/h2&gt;

&lt;p&gt;There is a genre of demo where a system appears intelligent because it produces a slick recommendation without showing intermediate reasoning. That can be impressive in a keynote. It is less helpful when you are trying to teach someone how to engineer coordination responsibly. I wrote ShootMesh-AI in the opposite style. The coordinator is short, the proposals are objects, and the ledger is readable. If the behavior looks wrong, you have a chance to pinpoint why.&lt;/p&gt;

&lt;p&gt;In my opinion, that style aligns with how many engineers prefer to learn. Show the data structures, show the decision rule, show the log line. Then discuss improvements with specificity rather than vibes.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you fork it, here is what I hope you change
&lt;/h2&gt;

&lt;p&gt;Forks are welcome in spirit even if I do not maintain a community process around this repository. If you fork it, I hope you rename the incident taxonomy to match a domain you understand deeply. I hope you rewrite proposals so they cite constraints that matter in your world. I hope you adjust the priority order thoughtfully and document why. I hope you keep a ledger-like artifact even if you change everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Narrative honesty about limitations
&lt;/h2&gt;

&lt;p&gt;I want to repeat plainly that this is a simulation. It does not capture the emotional load of a day running long. It does not capture union rules, child actor hours, or vendor contracts. It does not capture the creative discussion between a director and a cinematographer about whether a compromise is artistically acceptable. Those omissions are not oversights; they are outside the PoC’s scope.&lt;/p&gt;

&lt;p&gt;The value, as I see it, is in the skeleton. Skeletons are easy to criticize for being incomplete, but they are also easier to improve than a monolith that tries to be everything on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Additional remarks on tooling choices for diagrams and animation
&lt;/h2&gt;

&lt;p&gt;I used Mermaid because it is text-first and reviewable in pull requests. I used mermaid.ink to render PNGs because it keeps the article and README consistent without requiring readers to install diagram tooling. I generated the GIF locally because I wanted control over palette behavior and terminal styling. Each choice prioritized reproducibility and platform compatibility over novelty.&lt;/p&gt;

&lt;h2&gt;
  
  
  What “success” meant for this PoC
&lt;/h2&gt;

&lt;p&gt;I considered the PoC successful when I could run one command, see a legible ledger in the terminal, see charts that matched the ledger, and explain the merge policy without referring to hidden state. That bar sounds low, yet it eliminated a surprising number of flashy but opaque designs I tried first.&lt;/p&gt;

&lt;h2&gt;
  
  
  A final note on writing style and intent
&lt;/h2&gt;

&lt;p&gt;I varied first-person phrasing on purpose. I wanted the article to read like a practiced engineer narrating decisions, not like a marketing page. I avoided implying employer context or production guarantees. I kept the focus on what I built, why I built it, and how you can inspect it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ethics, labor, and authority
&lt;/h2&gt;

&lt;p&gt;Coordination software for creative work sits near sensitive realities: labor rules, safety jurisdiction, and creative authority structures. I am writing this article as a personal experiment, but I still want to state plainly that no repository should override qualified humans on set, and no simulation should be mistaken for compliance tooling. The PoC does not ingest personal data about performers or crew. It uses synthetic strings. Any future version that touches real schedules or personal information would require privacy review and clear data handling practices that go beyond this write-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Future roadmap from my notebook
&lt;/h2&gt;

&lt;p&gt;If I continue, I would explore the following directions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Replace static proposal text with retrieval over structured call-sheet data for a single fictional production, still without real people’s information.&lt;/li&gt;
&lt;li&gt;Add a second merge layer that models explicit director or producer overrides with ledger annotations so policy exceptions remain traceable.&lt;/li&gt;
&lt;li&gt;Introduce lightweight property tests that randomize incident order within a day to see if implicit assumptions hide in the scenario list.&lt;/li&gt;
&lt;li&gt;Export the ledger to a small SQLite file for longer runs, keeping the same schema principles.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of that is promised. It is a candid list of what I would try next if I kept the experiment alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;I built ShootMesh-AI because I wanted a hands-on way to think about multi-agent coordination as a set of proposals under a visible policy, not as a single opaque recommendation. From my observation, the hardest part was not typing Python; it was deciding what “winning” means when values genuinely conflict. The code forced me to be honest about trade-offs. The ledger forced me to accept accountability for those trade-offs in a repeatable record.&lt;/p&gt;

&lt;p&gt;In my opinion, that combination is portable beyond the film and television metaphor. Any domain with competing constraints can benefit from the same separation: specialized proposal generators, an explicit merge policy, and an append-only narrative of decisions. I am sharing this experiment in public so others can reuse the structure, critique the policy, or replace the scenario data with something closer to their own world.&lt;/p&gt;

&lt;p&gt;As per my experience, the most useful PoCs are the ones you can explain without hand-waving. I hope this article reads that way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclaimer
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Tags: python, agents, coordination, ai&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>agents</category>
      <category>coordination</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
