How I Defined Treatment, Stability, and Digitization in One Model
TL;DR
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.
The semantic model is schema/archive.yaml. The fact grain is Item. The dimensions are Collection, Location, and Format. The judgments I was willing to name are NeedsTreatment, Stable, DigitizeNext, and PullRisk. A metric that points at a property the item does not have dies in load_ontology with a SchemaError. I added a test that tries to score appraisal_dollars 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.
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 https://github.com/aniket-work/FolioSchema.
python main.py prints that table. FastAPI serves the same build_report 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.
Introduction
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.
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.
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.
IT-10 is the boundary I care about for treatment. Oral history tape, 1980, score exactly 2, not digitized, pulled twice, empty note. NeedsTreatment 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 <= I would misread in March.
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.
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.
What's This Article About?
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.
You will see why score 3 is a gap on purpose, why DigitizeNext can be true when NeedsTreatment 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.
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 https://github.com/aniket-work/FolioSchema. README.md has the same images and the same counts.
Tech Stack
I kept the list short. A model experiment that needs a graph database before the first table is an experiment I will not rerun.
- Python 3.11 is the runtime I used while writing the tests.
-
PyYAML reads
schema/archive.yaml. I did not invent a second file format. - Dataclasses hold the class, property, relationship, and metric definitions after the load.
-
The validator in
ontology.pyrejects duplicate names, missing relationship targets, and metrics that name a property the grain does not have. -
The binder in
bind.pychecks keys, relationship targets, and the numeric windows on score, year, and pulls. -
FastAPI exposes
GET /api/briefand servesstatic/dashboard.html. - Matplotlib writes the treatment-rate bar chart, one bar per collection, with a line at 50 percent.
- pytest 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.
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.
The picture matches the runtime. main.py and the dashboard both call build_report. That function asks metrics.py to score rows. metrics.py reads rules off the ontology object. bind.py is the only module that joins an item to a collection, a location, and a format. Sample rows stay in data/archive.json so the article, the tests, and the GIF describe one run.
Why Read It?
Read it if you have a folder of notebooks that each define the same operational word. This experiment fails in an obvious way. If NeedsTreatment starts reading a property named appraisal_dollars, test_metric_cannot_name_a_missing_property 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.
A few choices are the ones I would repeat.
-
Pick the grain before the dimensions. I wrote
Itemas the only fact. Collection, location, and format exist because an item points at them, not because a diagram looked empty without them. -
Put judgments in metrics and observations in properties.
condition_scoreis an observation.NeedsTreatmentis a judgment with a maximum. Mixing those in one column is how my old sheets drifted. - Fail the file, not the row, when the model is wrong. An unknown property is a schema bug. A brittle volume is a data fact. Those should not share an error path.
-
Keep one brief function. 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
<= 2into the YAML, both clients picked it up. - Leave a gap between the bad call and the good call. Score 3 is neither treatment nor stable. I wanted a row that the brief cannot flatter. IT-01 is that row.
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.
Let's Design
Before classes, I wrote the question and the cutoff on paper.
- Question. Which collections have a needs-treatment rate at or above 50 percent?
- Grain. One item, meaning one physical object, not a box and not a patron.
-
Needs treatment.
condition_score <= 2. - Also show. 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.
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.
One fact, three dimensions
A semantic model gets muddy when two things both claim to be the row. I made Item the only kind: fact. 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.
-
Collection is the group I want the rate over. Town council minutes, flood photographs, oral history tapes. The key is
collection_id. - Location 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.
- Format 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.
The item relationships are held_in to Collection, stored_at to Location, and recorded_as 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.
Properties are what I observed
On Item the properties are the things I could imagine reading off a folder label.
-
year_createdis 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. -
condition_scoreis 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. -
digitizedis 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. -
handling_countis 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. -
noteis free text. The model does not interpret it. The report looks for six words after scoring.
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.
Metrics are the judgments
Four metrics, all on Item.
-
NeedsTreatment uses rule type
at_most. Propertycondition_score, maximum 2. The result is a boolean. -
Stable uses
at_least. Propertycondition_score, minimum 4. Score 3 matches neither rule. I checked IT-01 for that. -
DigitizeNext uses
undigitized_and. 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." -
PullRisk uses
both_bounds. 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 asno, not as a maybe. Maybes were the spreadsheet habit.
I almost added TreatmentRate 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. collection_treatment_rate in metrics.py 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.
The 50 percent flag lives in report.py as TREATMENT_CUTOFF. 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.
Constraints the binder actually checks
The YAML lists three constraints in prose, and the binder enforces the same three in code. I do not parse the prose. The prose is there so I remember why the raise exists.
-
condition_scoreis an integer from 1 through 5. -
year_createdis from 1800 through 2020. The window is wide enough for this sample and narrow enough to catch a year I typed as 19370. -
handling_countis zero or greater.digitizedmust be a real boolean, not the string"false", which has bitten me in JSON more than once.
Unknown relationship targets fail in the binder when the JSON names a collection that is not in the file. Missing classes fail earlier, in validate_ontology, 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.
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.
What I left out of the model
Damage terms are the obvious omission. foxing, brittle, curl, silvering, sticky, and shed live as a tuple in ontology.py, and the scorer scans the note. I thought about a metric called Damaged with a keyword rule. I left it out because a keyword list is a scout, and I have watched scouts pretend to be definitions. The brief still prints the words. They do not change NeedsTreatment. 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.
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 sticky and shed, so that one note counts as two word hits inside one item. scout_notes 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.
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.
The twelve rows, said in sentences
I want the sample to be checkable without opening the JSON. These are the calls the tests lock.
- IT-01, minutes, vault, paper, 1924, score 3, not digitized, 4 pulls, foxing. Treatment no, stable no, digitize yes, pull no. The watched row.
- IT-02, minutes, vault, paper, 1948, score 4, digitized, 1 pull, empty note. Stable only.
- IT-03, minutes, reading room, paper, 1962, score 5, digitized, 8 pulls. Stable only. The popular sound volume. Eight pulls do not create pull risk.
- IT-04, 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.
- IT-05, photographs, cold room, 1937, score 2, not digitized, 3 pulls, curl. Treatment yes, digitize yes, pull no.
- IT-06, photographs, cold room, 1955, score 4, digitized, 1 pull. Stable only.
- IT-07, photographs, reading room, 1978, score 5, not digitized, 6 pulls. Stable, and not next to scan, and not a pull risk. The holdout.
- IT-08, photographs, cold room, 1941, score 1, not digitized, 0 pulls, silvering. Treatment yes, digitize yes. Nobody pulled it. Decay without popularity.
- IT-09, tapes, cold room, 1972, score 2, not digitized, 5 pulls, sticky shed. Treatment yes, digitize yes, pull yes. Smallest pull-risk yes.
- IT-10, tapes, cold room, 1980, score 2, not digitized, 2 pulls, empty note. Treatment yes because 2 is included. The boundary.
- IT-11, tapes, vault, 1968, score 4, digitized, 1 pull. Stable only. The tape that keeps the collection from being 4 of 4.
- IT-12, tapes, cold room, 1975, score 1, not digitized, 7 pulls, oxide shed. Treatment yes, digitize yes, pull yes.
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. worst_collection is the first row after sorting by rate descending, then by id. With these numbers that is COL-TAPE. If two collections ever tied, the id order would decide, and I would not pretend the tie was a judgment.
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.
Let's Get Cooking
This is the part I reread when I forget which check belongs to the file and which belongs to the row.
The metric block
I wrote the judgments in YAML so the cutoffs would be visible without opening the scorer.
metrics:
- name: NeedsTreatment
grain: Item
definition: Condition score is 2 or lower. Score 3 is watched, not queued.
rule:
type: at_most
property: condition_score
maximum: 2
- name: Stable
grain: Item
definition: Condition score is 4 or higher.
rule:
type: at_least
property: condition_score
minimum: 4
- name: DigitizeNext
grain: Item
definition: >
No usable surrogate, and either the score is 3 or lower or the object
was made in 1940 or earlier.
rule:
type: undigitized_and
property_flag: digitized
score: condition_score
score_maximum: 3
year: year_created
year_maximum: 1940
- name: PullRisk
grain: Item
definition: Pulled at least five times in the sample year while the score is 3 or lower.
rule:
type: both_bounds
low_property: condition_score
low_maximum: 3
high_property: handling_count
high_minimum: 5
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 > on DigitizeNext 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.
Refuse a metric that invents a property
validate_ontology walks each metric and checks the property names against the grain class. This is the whole point of loading the model before scoring.
def _check_rule(metric: MetricDef, grain: ClassDef) -> None:
rule = metric.rule
kind = rule.get("type")
names = grain.property_names()
def need(prop_key: str) -> None:
prop = rule.get(prop_key)
if not isinstance(prop, str) or prop not in names:
raise SchemaError(
f"metric {metric.name} on {grain.name} names unknown property {prop!r}"
)
if kind == "at_most":
need("property")
if not isinstance(rule.get("maximum"), int):
raise SchemaError(f"metric {metric.name} needs an integer maximum")
elif kind == "at_least":
need("property")
if not isinstance(rule.get("minimum"), int):
raise SchemaError(f"metric {metric.name} needs an integer minimum")
elif kind == "undigitized_and":
need("property_flag")
need("score")
need("year")
if not isinstance(rule.get("score_maximum"), int):
raise SchemaError(f"metric {metric.name} needs an integer score_maximum")
if not isinstance(rule.get("year_maximum"), int):
raise SchemaError(f"metric {metric.name} needs an integer year_maximum")
elif kind == "both_bounds":
need("low_property")
need("high_property")
if not isinstance(rule.get("low_maximum"), int):
raise SchemaError(f"metric {metric.name} needs an integer low_maximum")
if not isinstance(rule.get("high_minimum"), int):
raise SchemaError(f"metric {metric.name} needs an integer high_minimum")
else:
raise SchemaError(f"metric {metric.name} has unknown rule type {kind!r}")
need 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.
The test builds that failure on purpose. It loads the real YAML, appends a metric called InsuredValue with property appraisal_dollars, and expects SchemaError. 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.
Unknown rule types fail in the same function. I would rather crash on type: python than grow an eval. 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.
How DigitizeNext actually decides
The combination rule is the one I expect to misread, so here is the scorer without the other branches.
if kind == "undigitized_and":
if row[rule["property_flag"]] is not False:
return False
weak_score = int(row[rule["score"]]) <= int(rule["score_maximum"])
early_year = int(row[rule["year"]]) <= int(rule["year_maximum"])
return weak_score or early_year
The is not False check is stricter than if row["digitized"]. I want a real boolean. If a future row stored 0 or "no", I would rather throw when the binder runs than treat it as a clever false. The binder already requires isinstance(..., bool) before this function sees the row. The scorer still asks for False explicitly, because I have been burned by truthiness.
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.
One brief, two clients
build_report is the only place that counts. It scores, groups, and picks the weak collections.
TREATMENT_CUTOFF = 0.50
weak = [row for row in rates if row["rate"] >= TREATMENT_CUTOFF]
The comparison is >=, not >. 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. worst_collection is weak[0] after the rate sort. Tapes win that sort at 0.75.
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."
What a green test does not prove
I want this distinction written down where the code is, not only at the end.
- YAML syntax. If the indentation breaks, PyYAML raises before my validator runs. That only proves the file is YAML.
-
Model checks.
validate_ontologyproves 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. -
Row checks.
load_archiveproves unique ids, relationship ids that exist in the sample, and the three windows on score, year, and pulls. -
Rule execution.
score_itemsapplies the four rule types. It does not send SQL anywhere. There is no warehouse in this project. - Business agreement. 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.
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.
Let's Setup
Step by step details can be found at https://github.com/aniket-work/FolioSchema.
I used a Mac and a local virtualenv inside the project directory. From the clone:
git clone https://github.com/aniket-work/FolioSchema.git
cd FolioSchema
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
requirements.txt is PyYAML, FastAPI, Uvicorn, Matplotlib, and pytest. No database driver. No cloud SDK. If python3 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 PYTHONPATH=. so I do not have to install the package into site-packages for a twelve-row experiment.
The model file is schema/archive.yaml. The rows are data/archive.json. If you move either one, main.py will not find them, because it resolves paths from the file location of main.py, 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.
Let's Run
From the project root, with the virtualenv active:
PYTHONPATH=. python main.py --chart images/treatment-chart.png
This is the table I got, copied from that run.
ontology cedar_hollow_archive v0.3
fact grain: Item
metric NeedsTreatment on Item: condition_score <= 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
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.
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.
The tests are the same numbers:
PYTHONPATH=. python -m pytest -q
Six tests passed when I ran them for this write-up. One of them points held_in at COL-MISSING and expects BindError. That is the row-level failure. The schema failure is the invented appraisal_dollars metric. I keep both tests because I have, more than once, "fixed" a validator until it only caught the bug I was staring at.
The HTTP path is optional. I still run it when I want to see that the dashboard is not a second scorer.
PYTHONPATH=. uvicorn api:app --port 8077
Then GET /api/brief returns worst_collection of COL-TAPE, needs_treatment of 6, and digitize_next of 7. The page at / 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 build_report, not in the HTML.
A deliberate bad model
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 SchemaError formats:
metric InsuredValue on Item names unknown property 'appraisal_dollars'
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, load_ontology 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.
Closing Thoughts
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.
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.
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.
The code, the chart, and the GIF are in https://github.com/aniket-work/FolioSchema.
Disclaimer
The views and opinions expressed here are solely my own and do not represent the views, positions, or opinions of my employer or any organization I am affiliated with. The content is based on my personal experience and experimentation and may be incomplete or incorrect. Any errors or misinterpretations are unintentional, and I apologize in advance if any statements are misunderstood or misrepresented.
Tags: python, yaml, opensource, data






Top comments (0)