How judgments work

September 19, 2026 · View on GitHub

A judgment is one Jev call plus a pure function. This page lists every question Winnow asks, the exact order of the verdict rules as implemented in src/jev/verdict.ts, and how the numbers on the card are derived. Nothing here is prose from the model; Jev only ever returns probabilities.

Jev in one paragraph

Jev (jev-1.13.0, pinned in src/config.ts) takes one JSON state and a map of typed questions, evaluates every question against the state in parallel, and returns one answer per question:

TypeYou sendYou get back
noulinstructions, optional criteria: { true, false }noul: P(yes) in [0, 1]. No confidence field; Winnow uses `
choiceinstructions, criteria: { option: description | null }choice (argmax), confidence, probabilities summing to 1
scoreinstructions, criteria: [level 0, level 1, …] (≥ 2)score (probability-weighted level index, fractional), confidence, probabilities, legend

Questions reference parts of the state by path in backticks, e.g. `content.text` or `items[3]`. Jev reads instructions literally, cannot count or do arithmetic, and cannot generate text; those constraints are why word counts, reading time, timestamps, and segment splitting live in code. The full observed contract, including error shapes and limits, is in jev-contract.md.

The state

Page mode (buildPageState):

{
  "reader": { "goals": "...", "frequent_topics": ["ai_ml"], "recently_read_titles": ["..."], "recently_skipped_titles": ["..."] },
  "content": {
    "kind": "article", "title": "...", "text": "...", "byline": "...", "site": "...", "reading_minutes": 9
  }
}

For a video with a transcript that splits into at least 2 segments, content.text is replaced by content.segments: [{ id: "s0", start_label: "0:00", text: "..." }, …] and duration_minutes replaces reading_minutes. A video with no usable transcript gets content.text = its description. Article text is capped at 24,000 chars by the extractor and the transcript at 24,000 chars across segments; capForBudget trims every text field proportionally if the whole request would exceed 256,000 chars (~64k tokens).

Feed mode (buildFeedRequest): the same reader plus items: [{ title, snippet?, text? }, …], and each question id is prefixed i<n>_.

The questions

Defined in QUESTIONS in src/jev/questions.ts, one per id in QUESTION_IDS (src/types.ts). "Body" below means `content.text` or `content.segments` in page mode and `items[n]` in feed mode.

IdTypeAsked forInstruction gistCriteriaFeeds
insight_densityscore, 5 levelsarticle, video, feedHow much specific, non-obvious, well-supported insight does the body contain?0 generic → 1 one point in filler → 2 one or two thin ideas → 3 several ideas, but all in standard references → 4 original data, derivations, measurements, first-hand experiencedensityDisplay (1–10), rules 4–7, card
already_known_to_readernoularticle, video, feedWould a reader with reader.goals, reader.frequent_topics, reader.recently_read_titles already know the main idea?true: standard knowledge for that history or restates a recent title; false: new or outside their topicsrules 3 and 6, card "Already known"
content_typechoice, 8 optionsarticle, video, feedWhat kind of content is the body?original_research, opinion, news, tutorial, listicle, rage_bait, advertorial, entertainment, each with a one-line descriptionrule 1, the claims post-rule, card
claims_supportednoularticle, videoAre the main factual claims supported by cited sources, data, or worked examples within the text?true: most key claims point to a named source or example present; false: asserted without supportclaims post-rule, card. Feed default 0.5
undisclosed_sales_pitchnoularticle, video, feedDoes the body steer the reader to buy or sign up for a specific product without disclosing that it is promotional?true: recommends a product the author benefits from, no disclosure; false: no push, or clearly labelledrule 2, card "Sales pitch"
ai_writtennoularticle, videoWas the body most likely generated by an AI language model?true: uniform rhythm, hedged generic phrasing, listicle scaffolding, no specifics; false: uneven rhythm, concrete detail, idiosyncratic voicerule 5, card. Feed default 0
payload_locationchoice, 4 optionsarticle, videoWhere is the most valuable idea concentrated?intro, middle, end, evenlycard "Payload". Feed default evenly (confidence 0)
payload_segmentchoice over s0sNvideo with ≥ 2 segmentsWhich of content.segments contains the core idea or main payoff?segment ids only, no descriptionsSkip-to chips, "Core idea at" reason
serves_reader_goalsnoularticle, video, feedDoes the item directly serve reader.goals? If goals are empty, would it interest a curious generalist wanting to learn something new?true: advances a stated goal (or teaches a generalist something new); false: unrelated or tangentialgoalFit in rules 6–7, "Serves your goals" reason
topicchoice, 18 optionsarticle, video, feedWhich single topic best describes the item?the TOPICS list; only consumer_tech_products, productivity_selfhelp, and other carry descriptionsreaderState.recordTopic (page mode only)
jev_verdictchoice, 4 optionsarticle, video, feedFor the reader described in reader, what should they do with the item?read_now, skim, save, skip, each with a one-line description referencing reader.goalsrule 8 tie-break and the verdict's confidence

FEED_QUESTION_IDS is derived from appliesTo and currently contains 7 ids: insight_density, already_known_to_reader, content_type, undisclosed_sales_pitch, serves_reader_goals, topic, jev_verdict. parseAnswers fills the unasked ones with neutral defaults that fire no rule.

Depth

Judgment.depth records how much of the item Jev saw, and the verdict rules gate on it:

  • Page mode, article: always full (the extractor already refused anything under 120 words).
  • Page mode, video with transcript: full. Without a transcript: snippet if the description is at least 200 chars, otherwise content_too_short.
  • Feed mode: snippet, unless a prefetched body has 400 or more words, in which case full. Prefetched text is capped at MAX_FEED_ITEM_CHARS (3,000 chars, roughly 500 words), so a prose page reaches full and the body-gated rules below apply; a short landing page stays snippet.

Density display

densityDisplay(score) maps the 0–4 expected level index to 1–10:

$ \text{display} = \text{clamp}(1, 10, \text{round}(1 + \text{score} \times 9 / (\text{levels} − 1))) $

With 5 levels: 0 → 1, 1 → 3, 2 → 6, 3 → 8, 4 → 10. The contract warns not to read exact numbers out of a score; this is a display convenience, and the eval goldens only constrain it with a min/max range.

The verdict rules, in order

computeVerdict(judgment, thresholds) is a single if / else if chain followed by one post-rule. t.* are the sliders from configuration.md; numbers in parentheses are the defaults. body is depth === "full". density is the display value. minutes is readingMinutes, or durationSec / 60 for videos. long is minutes > t.save_min_minutes (15). goalFit is serves_reader_goals ≥ t.read_now_min_goal_fit (0.6).

#Rule idConditionLabelReason line
1content_type_rage_bait / content_type_advertorialcontent_type is rage_bait or advertorial and its confidence ≥ 0.6 (fixed, not a slider)skip"Reads as rage bait (NN%)" / "Reads as an advertorial (NN%)"
2sales_pitchundisclosed_sales_pitch ≥ t.skip_min_sales_pitch (0.7)skip"Likely a sales pitch (NN%)"
3already_knownalready_known_to_reader ≥ t.skip_min_known (0.8)skip"You likely know this already (NN%)"
4low_densitybody and density ≤ t.skip_max_density (3)skip"Insight density N/10"
5ai_writtenbody and ai_written ≥ t.skip_min_ai_written (0.85) and density < t.read_now_min_density (7)skip"Probably AI-written (NN%)"
6read_now / read_now_long_savebody and density ≥ t.read_now_min_density (7) and already_known_to_reader ≤ t.read_now_max_known (0.5) and goalFitread_now, or save if long
7skim_long_savebody and goalFit and density ≥ 5 (fixed) and longsave
8jev_tiebreakjev_verdict.confidence ≥ 0.5 (fixed)whatever jev_verdict.choice is
9default_skimnone of the aboveskim

Then one post-rule:

Rule idConditionEffectReason line
unsupported_claimsbody and content_type is opinion or news and claims_supported < t.skip_max_claims_supported (0.3) and the label so far is read_now or skimread_now → skim; skim → skip"Claims mostly unsupported (NN%)" where NN = 100 × (1 − claims_supported)

Every rule that fires is appended to verdict.firedRules, so the eval harness and the dev preview can see why an item landed where it did.

Read now vs save: the shared confidence mass

Rules 6 and 7 decide between read_now and save purely on length, in code. Jev's jev_verdict question was asked the same four-way question, but it has no reliable way to know the reading time. So when the final label is read_now or save, the verdict's confidence is P(read_now) + P(save) from jev_verdict.probabilities. For skim and skip it is P(label).

If jev_verdict gave no probability for the final label (it always does for the four known labels, so this is a fallback), confidence is instead the mean of the contributions each fired rule pushed: choice confidences as-is, noul answers via the |p − 0.5| × 2 proxy, and the density score's own confidence.

Low confidence

If the confidence is below t.min_confidence (0.4), low_confidence is added to firedRules. The label is still computed and stored, but:

  • the feed badge shows ? instead of GO / ~ / SAVE / SKIP, with the tooltip "low confidence";
  • the card pill shows ? with "low confidence" instead of the label and its confidence word.

Everything else on the card (density, content type, reasons) renders normally. The confidence word on a normal pill is high (≥ 0.8), medium (≥ 0.5), or low; the badge also carries a coloured dot for the same three bands.

Reasons

verdict.reasons is a list of template strings, in this order: the reason from each rule that fired (see the tables above), then these always-on context lines, de-duplicated against what is already there:

  1. Insight density N/10
  2. Reads as <type> (NN%) — type text from TYPE_LABEL in verdict.ts ("original research", "a tutorial", "rage bait", …)
  3. Serves your goals (NN%) — only when goalFit
  4. Core idea at m:ss–m:ss — only for videos with a payload_segment answer
  5. ~N min read or ~N min watch — when a length is known

The card renders at most the first 4 (renderCardBody slices reasons to 4). Percentages are Math.round(p × 100).

Payload timestamps

payloadTimestamps(judgment) turns the payload_segment answer into chips:

  • The chosen segment becomes { start, end, label: "core idea" } using the boundaries stored on judgment.segments.
  • The highest-probability other segment is added as { label: "also" } if Jev gave it at least 0.25.

Segments come from segmentTranscript in src/jev/questions.ts: the caption entries are split into at most VIDEO_SEGMENTS (8) chunks of equal entry count, each chunk labelled s<i> with the start of its first entry and the end of its last, and each chunk's text capped at 24,000 / (number of segments) chars. Times are formatted with fmtTime as m:ss or h:mm:ss. Clicking a chip sets video.currentTime and plays.

From Judgment to the card

toCardModel(judgment, verdict, fromCache) copies the typed answers onto a CardModel: insightDensity (display value), alreadyKnown, contentType and its confidence, claimsSupported, undisclosedSalesPitch, aiWritten, payloadLocation, payloadTimestamps, length fields, fromCache, and judgedAt. kind is inferred as video when the judgment has segments, a duration, or a YouTube URL. The card renders each of these through src/ui/templates.ts; the badge renders VERDICT_TEXT[label].short and shows the same card body in its popover.