Skip to content

Analyze a profile

@azohra/meteo.briefing/analyze compresses one profile into a small, versioned vocabulary of findings. Each finding is a typed statement about magnitudes, timing, published absences, or arithmetic relationships in that document. Findings that depend on thresholds carry the thresholds that produced them; findings that cite hours carry the underlying values and UTC validAt instants in an evidence block.

Findings, drawn on the document they cite A teaching Meteogram with the thermalWindow finding computed by analyzeForecast overlaid as a highlighted band from 14:00 to 16:00, and the day's other findings listed with their evidence values.

Use @azohra/meteo.briefing/derive when you need quantities. Use @azohra/meteo.briefing/analyze when you need a statement that remains inspectable after the full profile is no longer in the immediate view or prompt.

@azohra/meteo.briefing/compare applies one analysis threshold set across multiple models and compares their findings.

Validate a profile before passing it to the analysis API:

analyze-profile.ts
import {
analyzeForecast,
type ForecastAnalysis,
} from "@azohra/meteo.briefing/analyze";
import { parseSiteForecastJson } from "@azohra/meteo.briefing/contract";
export function analyzeProfileJson(text: string): ForecastAnalysis {
const profile = parseSiteForecastJson(text);
if (!profile) throw new Error("invalid profile");
return analyzeForecast(profile, {
// The launch is yours, not the document's — typically site-context.json's
// elevation pick. It anchors every launch-relative statement.
launch: { elevationM: 1591 },
thresholds: {
thermalWindow: { wstarMinMps: 1.0, depthMinM: 350 },
},
});
}

launch is optional and mirrors the scene’s MeteogramOptions.launch: documents are launch-agnostic, so the caller names the launch the analysis reads against. Without one the analysis degrades gracefully rather than guessing — launch-relative arithmetic (the thermalWindow depth threshold) falls back to the model’s own ground (site.modelElevationM), peakLiftTopAboveLaunchM is null instead of a number relative to the wrong ground, and terrainMismatch — a launch-vs-model-ground statement — is never emitted. The analysis envelope records the launch it used as site.launchAltitudeM (null when none was supplied).

thresholds is optional. Overrides are merged by finding kind over DEFAULT_ANALYZE_THRESHOLDS. They are conventions chosen by the caller, not new physics, and the effective values are copied into every finding they shape. The defaults (release-current values of DEFAULT_ANALYZE_THRESHOLDS):

KindDefault thresholds
thermalWindowW* ≥ 0.9 m/s and depth ≥ 300 m above launch; gap tolerance 0 h
liftCeilingcloud-cap margin 50 m
capTiminginstability from 100 J/kg; broken cap at ≤ 25 J/kg CIN with ≥ 200 J/kg CAPE; precipitation from 0.2 mm/h
convectiveDayprecipitation from 0.2 mm/h
terrainMismatchreported from 250 m absolute delta
windSummaryclimb band padded 200 m; persistence within 0.8 of the peak
windDirectiondirection suppressed under 1 m/s
bandShearlayers thinner than 30 m skipped; light-endpoint relation at 2 m/s

thermalWindow.maxGapHours is a segmentation tolerance: adjacent passing runs merge when the failing steps between them cover at most that many hours and every bridged step publishes both series (bridging a data hole would manufacture continuity the model never forecast). The default 0 merges nothing — exactly the pre-v4 segmentation. Bridged hours join the cited evidence, so the dip stays visible.

AnalyzeOptions carries two more caller-owned inputs beside launch:

smoke joins a same-site smoke document (RAQDPS) beside a smoke-blind profile, by exact validAt match. The smokeImpact kind then republishes the smoke run’s surface and column magnitudes with a coverage confession and the smoke run’s own referenceTime beside the envelope’s. It is ignored when the profile carries its own hours[].smoke (the model’s own smoke wins). Absent both, the analysis is smoke-blind and says so with the dataCaveats "smoke" family token — absence means “not published”, never clear air.

windCeilings feeds windExceedance, and is deliberately not in thresholds, because no defaults exist: the package never owns a “safe wind” number. Without a ceiling the kind emits nothing; each supplied value is echoed verbatim in the findings it produces. Gust ceilings are per declared semantics class (gust.hourMaxMps / gust.instantMps) and are never reused across classes — the two classes measure a factor ~1.8–2.8 apart at matched means, so one number cannot serve both.

analyze-with-inputs.ts
import { analyzeForecast } from "@azohra/meteo.briefing/analyze";
import {
parseSmokeDocumentJson,
parseSiteForecastJson,
} from "@azohra/meteo.briefing/contract";
export function analyzeWithInputs(profileText: string, smokeText: string) {
const profile = parseSiteForecastJson(profileText);
if (!profile) throw new Error("invalid profile");
return analyzeForecast(profile, {
launch: { elevationM: 1591 },
// Same-site RAQDPS document; ignored when the profile has its own smoke.
smoke: parseSmokeDocumentJson(smokeText),
// YOUR conventions for one pilot at one site — not recommendations, and
// not package defaults: omit a ceiling and that quantity emits nothing.
windCeilings: {
surfaceMps: 7,
gust: { hourMaxMps: 11, instantMps: 8 },
bandMps: 9,
},
});
}

Narrow findings by their kind discriminant. This example prepares rows for a teaching table while retaining the exact series and instants behind every window.

window-rows.ts
import type { ForecastAnalysis } from "@azohra/meteo.briefing/analyze";
export function windowRows(analysis: ForecastAnalysis) {
return analysis.findings.flatMap((finding) => {
if (finding.kind !== "thermalWindow") return [];
return [{
day: finding.day,
// Hours from the run's referenceTime to the peak-lift hour: a day-10
// window and a day-1 window are different objects wearing the same
// vocabulary, and only this field says which one you hold.
leadHours: finding.leadHours,
localStart: finding.start.local,
localEnd: finding.end.local,
// The widest covered step among the cited hours — the quantization
// bound on this window's timing and duration.
stepHours: finding.stepHours,
peakAboveLaunchM: finding.peakLiftTopAboveLaunchM,
thresholds: finding.thresholds,
evidence: {
validAt: finding.evidence.hours,
usableLiftTopM: finding.evidence.usableLiftTopM,
thermalVelocityMps: finding.evidence.thermalVelocityMps,
liftTopBandP10P90: finding.evidence.liftTopBandP10P90,
},
}];
});
}

Do not reduce a finding to a prose label before storing its thresholds and evidence. Those fields are what make the compressed statement auditable.

Since the Windgram-era 0.22 release, everything a downstream comparison validates or states about a member is on the envelope, so a serialized ForecastAnalysis re-enters compareAnalyses without re-opening the profile:

  • thresholds — the complete resolved threshold set this analysis ran under (per-finding echoes are absent when a kind emitted nothing; this echo never is);
  • deterministic — whether the document is deterministic or an ensemble read at p50, precomputed;
  • coveredDays — the local calendar days the document’s hours actually touch, computed in the envelope’s own timeZone from hours[].validAt and never from cadence arithmetic (live documents widen their step mid-horizon);
  • extensions — named third-party statements, when extensions were passed; absent, not empty, otherwise, so envelopes serialized before then are byte-identical.

These are required fields, which is additive for every reader of the envelope — only code that constructs ForecastAnalysis values by hand (test fixtures) gains fields to fill. Analyze once at the edge, cache the envelope as JSON, and compare later: the self-description is what a later compare validates against.

vocabularyVersion is typed number, not the version literal — the one type-level break of that release, with zero wire change. It encodes the tolerant-reader convention: consumers of serialized envelopes must ignore finding kinds and envelope fields they do not know, so additive kinds bump the version number without breaking any conforming reader. Readers check the stamp at runtime (compareAnalyses throws on skew) instead of recompiling on every bump, and cached envelopes survive package upgrades as data.

An exhaustive switch over finding.kind stays available to compiled consumers — with a default arm it too is conforming:

tolerant-reader.ts
import { ANALYZE_VOCABULARY_VERSION, type ForecastAnalysis } from "@azohra/meteo.briefing/analyze";
export function dayVerdicts(envelope: ForecastAnalysis) {
if (envelope.vocabularyVersion > ANALYZE_VOCABULARY_VERSION) {
// A newer package produced this envelope. Additive kinds are the
// normal growth mode: read the kinds you know, ignore the rest.
}
return envelope.findings.flatMap((finding) => {
switch (finding.kind) {
case "thermalWindow":
return [{ day: finding.day, window: true }];
case "quietDay":
return [{ day: finding.day, window: false }];
default:
// The default arm is what makes a compiled switch conforming:
// an unknown kind is ignorable, never an error.
return [];
}
});
}

The convention governs readers of the closed set, never the set itself: unknown kinds are ignorable, not admissible. Nothing enters findings without the evidence spike that gates the vocabulary — third-party statements have their own door, below.

ANALYZE_VOCABULARY_VERSION is currently 5. Adding, renaming, or removing a kind is an analysis-contract event, independent of the profile schemaVersion.

Renamed at vocabulary 5: the envelope’s bare-Ms quantity fields take the profile contract’s Mps suffix grammar (wstarMinMps, peakThermalVelocityMps, meanWindMps, and their siblings). No kind was added or removed; serialized vocabulary-4 envelopes keep their old field names, and the runtime version check catches the skew.

Renamed at vocabulary 4: flyableWindow is now thermalWindow. The kind string is the one token every consumer switches on, and “flyable” was the one judgment word that did not reduce to the stated arithmetic — the test reads two thermal quantities (W* and usable-lift depth) against stated floors and is blind to wind, rain, and overdevelopment. The new name says what the arithmetic tests; the flyability call stays downstream, where it always belonged. If your code switches on "flyableWindow" or overrides thresholds.flyableWindow, both spellings are now thermalWindow.

Removed at vocabulary 4, because live documents measured each as an artifact rather than a statement:

  • ensembleMembership.bands[].trend and its wideningRatio threshold — the first-vs-last verdict was a diurnal confound in both directions (a run whose first or last hours are night reads a zero band width at the edge, so the verdict measured where the horizon ends, not spread growth). The replacement is dayBands: the per-local-day band-width series itself, read at each day’s peak-p50-W* hour, with no monotonicity verdict of any kind.
  • ensembleMembership.maxRelativeSpread / maxSpreadAt — width divided by p50 explodes as p50 approaches zero, so the value pointed at the least consequential hour. The evidence arrays keep the underlying series.
  • liftCeiling.flips — it restated segments.length - 1 and compressed nothing.
Finding kindWhat it statesEvidence and limits
thermalWindowConsecutive hours meeting the embedded W* and launch-relative depth thresholds, with leadHours to the peak and its own stepHours quantization boundclippedAtStart / clippedAtEnd mark edges set by the document horizon; maxGapHours may bridge published sub-threshold dips, never data holes
percentileCrossingEnsemble days where some published percentile’s day verdict differs from p50’s, under thermalWindow’s exact floorsCites passing instants only, never windows — percentiles are per-hour marginals, not member trajectories; carries per-percentile member counts and leadHours
quietDayA local day produced no thermal window, which floors its best hours missed, and the atmospheric context beside the arithmeticcontext restates the document’s own precipitation, cloud, gust, and heat-flux series with no causal verdict; leadHours and a coverage.truncated confession ride every statement
convectiveDayCAPE magnitude and precipitation timing for models that publish CAPE and no CINcapIsJudgeable is always false — absent CIN must never read as “no cap”; CAPE magnitudes are model-specific and never comparable across documents; mandatory coverage
liftCeilingWhether each segment’s arithmetic ceiling is cloud-capped or sink-limitedEach segment cites its peak lift top with cloud base and BL top sampled at that same hour, so the cause relation is checkable against co-timed values
capTimingCAPE build, CIN erosion, and precipitation timing relative to a windowDeterministic documents with CIN only. cadence selects the verdict semantics: hourly days cite the broken hour (capBreaksAt); multi-hour days cite the interval between published steps (capBreaksBetween) or a day-edge capAlreadyOpenAt. openButWeak names a cap that sat open all day while CAPE never cleared the break floor
smokeImpactDay-peak and during-window smoke magnitudes — republished numbers only, no derate verdictProfile-sourced days carry the model’s own AOT; joined (RAQDPS) days carry the column mass, the smoke run’s own referenceTime, and a per-day join-coverage count. The semantics echo says whether the lift numbers already feel this smoke
windSummaryMaximum gust and climb-band wind magnitudes, timing, altitude, and persistenceThe whole-day maxima and the duringWindow block answer different questions — the strongest gust of the day is outside the window often enough that the airborne-hours number is its own block
windExceedanceMaximal runs of window hours at or above a caller-supplied ceilingEmits nothing without AnalyzeOptions.windCeilings — the package owns no safe-wind number; the caller’s ceiling is echoed verbatim, and gust ceilings never cross semantics classes
windDirectionSurface-flow evolution across a window: start / peak-lift / end samples, net circular veer, vector meansDeterministic documents only — ensemble percentiles of raw degrees are not circular statistics. netVeerDeg is start→end displacement, never accumulated rotation, and is blind to a full 360° loop
bandShearThe strongest adjacent-layer shear rate inside the climb band, with its mandatory layer boundsAnalyze-only, never compared: rates are not comparable across level densities. Sparse columns rarely emit — absence means “too sparse to state”, never “no shear”
terrainMismatchGrid terrain delta and whether published lift ever arithmetically reaches the caller’s launchEmitted only when AnalyzeOptions.launch is supplied and the embedded mismatch threshold is met; evidence carries the max p90 lift top so the bench is checkable at the band’s top
ensembleMembershipContributor-count loss, p10–p90 band-width magnitude, and the per-day dayBands width seriesSpread and membership are not a confidence interval or confidence score; dayBands rows carry leadHours and a truncated flag, and no trend verdict exists
dataCaveatsAbsent quantity families, derived-null hours, coarse cadence, or UTC fallbackThreshold-free; absence remains “not published,” never zero — including the "smoke" family, where absence is never clear air

Each example below compiles against the released package and keeps the finding’s own caveats visible instead of flattening them away.

percentileCrossing is ensemble-only and emits only where a percentile’s day verdict disagrees with p50’s — a day where every percentile agrees emits nothing, on either side. It cites passing instants, never windows: the members composing p90 at 11:00 need not be the members composing it at 17:00, so no “p90 window” exists to state.

upside-days.ts
import type { ForecastAnalysis } from "@azohra/meteo.briefing/analyze";
export function upsideDays(analysis: ForecastAnalysis) {
return analysis.findings.flatMap((finding) => {
if (finding.kind !== "percentileCrossing") return [];
const p90 = finding.perPercentile.p90;
return [{
day: finding.day,
// The p50-quiet/band-window state concentrates at long lead; never
// present a crossing as near-term hidden upside without this number.
leadHours: finding.leadHours,
minimalPassingPercentile: finding.minimalPassingPercentile,
p50PassingSteps: finding.perPercentile.p50.passingSteps,
p90PassingSteps: p90.passingSteps,
// A "p75" over 12 contributing members is a different object than
// one over 21 — the echo, not the label, is the guarantee.
fewestContributingMembers: p90.membersMin,
}];
});
}

smokeImpact republishes numbers and deliberately states no derated window and no adjusted W* — the only live passive column source measured far below a satellite-verified column (the RAQDPS column field is quarantined from derived optics; the contract’s smokePlumeColumnMgm2 note is that fact’s one home), and even satellite-magnitude optics flipped almost nothing. The semantics echo is load-bearing: "radiativelyCoupled" means the document’s own lift numbers already feel this smoke, so any downstream derate double-counts.

smoke-rows.ts
import type { ForecastAnalysis } from "@azohra/meteo.briefing/analyze";
export function smokeRows(analysis: ForecastAnalysis) {
return analysis.findings.flatMap((finding) => {
if (finding.kind !== "smokeImpact") return [];
return [{
day: finding.day,
semantics: finding.semantics,
peakSurfaceUgm3: finding.peakSurfaceUgm3,
// Day peak and in-window maximum are materially different facts; a
// null duringWindow means no window, or no smoke hour landed on one.
inWindowSurfaceUgm3: finding.duringWindow?.maxSurfaceUgm3 ?? null,
// The source's second number: the model's own AOT on profile days;
// on joined (RAQDPS) days the republished column mass, the smoke
// run's own referenceTime beside the envelope's, and how many
// profile hours the join actually covered.
...(finding.source === "profile"
? { peakAot: finding.peakAot }
: {
peakColumnMgm2: finding.peakColumnMgm2,
smokeRun: finding.smokeRun,
coverage: finding.coverage,
}),
}];
});
}

convectiveDay exists for models that publish CAPE and no CIN, where capTiming’s gate would otherwise leave a washout day saying nothing about instability. It is deliberately unable to say “uncapped”.

convective-rows.ts
import type { ForecastAnalysis } from "@azohra/meteo.briefing/analyze";
export function convectiveRows(analysis: ForecastAnalysis) {
return analysis.findings.flatMap((finding) => {
if (finding.kind !== "convectiveDay") return [];
// A truncated day's peaks are peaks OF THE COVERED HOURS only — live
// horizon slivers carry nocturnal CAPE peaks cited at 01:00-05:00.
if (finding.coverage.truncated) return [];
return [{
day: finding.day,
// Never compare across documents: CAPE magnitudes are model-specific.
peakCapeJkg: finding.peakCapeJkg,
capIsJudgeable: finding.capIsJudgeable, // always false: no CIN published
precipStartsLocal: finding.precipStartsAt?.local ?? null,
// A 0.00 series is a FORECAST of dryness, not absence.
dryAboveFloor: finding.noPrecipAboveThreshold ?? false,
windowEndsLocal: finding.thermalWindowEndsAt?.local ?? null,
}];
});
}

windExceedance states where your own ceiling is met, and nothing else — see the analysis inputs for supplying windCeilings. A day without a thermal window emits nothing whatever the wind; absence on a window day means no window hour met the ceiling.

wind-alerts.ts
import type { ForecastAnalysis } from "@azohra/meteo.briefing/analyze";
export function windAlerts(analysis: ForecastAnalysis) {
return analysis.findings.flatMap((finding) => {
if (finding.kind !== "windExceedance") return [];
return finding.runs.map((run) => ({
day: finding.day,
quantity: finding.quantity,
thresholdMps: finding.thresholdMps, // your ceiling, echoed verbatim
// Present iff quantity is "gust": hourMax and instant exceedances
// must never be compared — the classes measure ~1.8-2.8x apart.
gustSemantics: finding.gustSemantics ?? null,
localStart: run.start.local,
localEnd: run.end.local,
// Covered span at the document's actual cadence; finding.stepHours
// is the quantization bound on this number.
hours: run.hours,
peakMps: run.peakMps,
}));
});
}

windDirection tells the drainage-to-upvalley story across one window, for deterministic documents only. All arithmetic is vector math; raw degrees are never averaged, and a sample under the embedded floor states its speed with a null bearing rather than a jittering direction.

flow-evolution.ts
import type { ForecastAnalysis } from "@azohra/meteo.briefing/analyze";
export function flowEvolution(analysis: ForecastAnalysis) {
return analysis.findings.flatMap((finding) => {
if (finding.kind !== "windDirection") return [];
return [{
day: finding.day,
startDeg: finding.surface.start.directionDeg,
peakLiftDeg: finding.surface.peakLift.directionDeg,
endDeg: finding.surface.end.directionDeg,
// Start-to-end circular displacement, never accumulated rotation —
// a flow that boxes the compass and returns reads as zero. The
// per-hour path stays in finding.evidence.
netVeerDeg: finding.netVeerDeg,
bandMeanDeg: finding.bandVectorMean?.directionDeg ?? null,
}];
});
}

bandShear is the height-resolved shear read: component-wise vector shear between adjacent published levels inside the climb band. It never joins a cross-model comparison — a sparse column reports a different, smeared layer, not a softer number — which is why the layer bounds and level count are mandatory in the shape.

shear-rows.ts
import type { ForecastAnalysis } from "@azohra/meteo.briefing/analyze";
export function shearRows(analysis: ForecastAnalysis) {
return analysis.findings.flatMap((finding) => {
if (finding.kind !== "bandShear") return [];
return [{
day: finding.day,
// The rate means nothing without its layer: "2.3 m/s/km across
// 1506-3129 m" must not be mistaken for a sharp shear zone.
ratePerKm: finding.maxShear.ratePerKm,
layer: finding.maxShear.layer,
levelsInBand: finding.levelsInBand,
// An arithmetic relation, not a verdict: both endpoint speeds sit
// under the embedded floor, so the "shear" may be a direction
// difference between two near-calm winds.
bothEndpointsUnderFloorMps: finding.bothEndpointsUnderFloorMps,
}];
});
}

The extraction frame — the normalization ground every first-party extractor stands on — is public since the Windgram-era 0.22 release: AnalysisFrame, versioned separately as ANALYSIS_FRAME_VERSION (the frame is where extractors stand, the vocabulary is what they say; the frame changes rarely, and a frame change is its own contract event). AnalyzeOptions.extensions runs caller extractors over it after first-party extraction, receiving the finished findings read-only.

The frame hands an extension the resolved per-analysis facts — timezone and its source, deterministic, the leading stepHours plus the per-gap steps truth, referenceTime, the launch resolution — and three bound functions, cite, dayOf, and leadHours, which are the three ways an extension gets midnight wrong on its own. The raw hour data stays available through frame.profile; @azohra/meteo.briefing/derive exports the same selectors the first-party extractors use (p50, localDateKey, groupByLocalDay).

window-pace-extension.ts
import { analyzeForecast, type AnalysisExtension } from "@azohra/meteo.briefing/analyze";
import type { SiteForecast } from "@azohra/meteo.briefing/contract";
/** The extension's OWN statement type. The vocabulary's guarantees stop
* at `findings`, so this contract is the extension's to state — and the
* house discipline (evidence, embedded thresholds) is documented but
* unenforceable expectation, yours to hold. */
interface WindowPaceStatement {
day: string;
citedHours: number;
leadHoursAtStart: number;
}
const windowPace: AnalysisExtension = {
// Namespaced, echoed verbatim on the envelope entry. Duplicate names
// in one call throw.
name: "example/windowPace",
extract(frame, findings) {
const statements: WindowPaceStatement[] = [];
for (const finding of findings) {
if (finding.kind !== "thermalWindow") continue;
statements.push({
// dayOf and leadHours are BOUND to this analysis's zone and run,
// so timezone and lead arithmetic are correct for free.
day: frame.dayOf(finding.start.validAt),
citedHours: finding.evidence.hours.length,
leadHoursAtStart: frame.leadHours(finding.start.validAt),
});
}
return statements;
},
};
export function paceStatements(profile: SiteForecast): WindowPaceStatement[] {
const analysis = analyzeForecast(profile, { extensions: [windowPace] });
// Statements land on the envelope's named `extensions` entry, NEVER in
// `findings` — they stay unknown[], and consumers narrow through the
// extension's own types, so no third-party statement can masquerade as
// a first-party finding.
const entry = analysis.extensions?.find((e) => e.extension === "example/windowPace");
return (entry?.statements ?? []) as WindowPaceStatement[];
}

A throwing extension fails the analysis — you supplied the code, and analyzeForecast does not sandbox it.

Three things are deliberately not exposed, and the absences are the design:

  • the extraction Context — it carries the full AnalyzeThresholds and WindCeilings, so exposing it would re-couple this rarely-changing surface to every vocabulary event. Extensions bring their own thresholds and are expected to embed them in their own statements, per the house discipline;
  • the citation and cadence factories — the frame carries their results (cite, dayOf, leadHours, steps), not the machinery;
  • the first-party kind extractors — extensions consume the finished findings; they do not re-run or re-order the pipeline.

analyzeForecast chooses its timezone in this order:

  1. options.timeZone, when supplied;
  2. the profile’s optional site.timeZone; then
  3. UTC for an older document, with timeZoneSource: "utcFallback" and a timesAreUtc data caveat.

Every CitedInstant keeps both its local label and the document’s UTC validAt, so a finding can join back to the source hour.

Cadence is read from the document’s actual per-gap spacing, never assumed constant: live documents widen mid-horizon (GEPS publishes 3-hourly, then 6-hourly). The envelope’s stepHours is the document’s leading cadence — a display fact — while every spacing-derived number inside a finding (durations, covered spans, truncation verdicts) reads the real gap at each step. Timing-sensitive findings carry their own stepHours echo: the widest covered step among the hours they cite, which bounds how finely their timings can be read. A mixed-cadence document also carries a stepCadence caveat naming its widest step.

Every finding day uses the exported LocalDayKey string type. Compute scene day windows and analysis with the same timezone so midnight does not split one local day across two keys.

resolveAnalyzeThresholds(overrides) returns the complete threshold set used by analyzeForecast and compareForecasts.

Findings serialize three ways: the full array (every finding with evidence), a filtered subset (only the kinds a surface presents), or a single finding’s evidence object. Measure the serialized result against the consuming surface’s actual input budget — a chat context, a webhook body, a UI panel — rather than assuming the full array fits; evidence dominates the byte count, and filtering by kind before serializing is usually the right first cut.