# optbrew readiness scoring contract (evaluation 0.5.0)

This is a readiness audit. It does not predict indexing, rank, AI citations, accuracy, or revenue. `check-registry.json` holds the unique families. `profiles.json` holds the five optbrew axis weight sets. Those weights, the middle-band CWV `q=0.5`, the score bands and the 80% coverage threshold are **optbrew rules**, not search engine weights or official sufficiency tests. `source_refs` names match the official references in `docs/superpowers/specs/2026-10-13-optbrew-sources.md` in the source repository.

## Fixed input

`ScoreInput` is `{schema_version:"2.0.0",run_context:RunContext,observations:CheckObservation[]}`. The JSON Schema in `schemas/contract.schema.json` names this and the nine persisted entities. `validateEntity(typeName,value)` returns the value or throws `ContractError`. `freezeProtocol(run_context)` returns `{protocol,protocol_hash}`. Its hash covers the selected URLs and page types, indexing intent, family condition codes, engines, location/language, panel and surface/model/search settings, browser/device, target, environment and separate evaluation/protocol versions. It excludes `run_id`, evidence paths, collection times and explanatory decision text. Changing a comparison condition requires re-evaluation from preserved raw data or `not_comparable`.

Contract 2.0.0 starts with optbrew 0.2.0. Evaluation 0.5.0 and protocol 0.3.0 (goals, the AI crawler and conversion families, confirmed facts) start with optbrew 0.3.0. Evidence an older release made (any of those labels older than these) is refused with `migration_required` and one sentence telling the user to run the audit again in a new output folder. `compare` reports such an input as `not_comparable` (`no_after` when only the baseline is given) instead of failing the schema. `InstallRecord` and the bundle manifest keep schema 1.0.0.

Every `run_context.protocol.families` entry is `{family_id,applicable,urls?,engines?,applicability_decision?}`. A `ScoreInput` must explicitly decide one entry for every registry family. False requires `{condition,reason,evidence_ref}` with a condition allowed for that family in `check-registry.json`; true forbids a decision. The reason and evidence reference must be nonempty. Unknown facts or missing collection data leave applicable units `unknown`; they do not remove denominator weight. `urls` and `engines`, when supplied, must equal the entire applicable selected set. To exclude one optional unit, submit an evidence-backed `not_applicable` observation for that unit. A subset in a family plan is rejected. NEO is reported only for a Naver target.

Every selected URL is a public audit target and always gets `fetch-response`. Its optional `public_indexing` defaults to true. An intentional non-index target sets `public_indexing:false` and supplies `indexing_decision:{condition:"not_public_index_target",reason,evidence_ref}`. The audit sets it itself, after collection, for a selected page that carries noindex in its raw response, is not the home page and is listed in no sitemap the run read, while the sitemaps it read list other pages of the same site and every declared sitemap was read in full. The rule is: when the evidence is incomplete or ambiguous, the noindex stays a failure and a blocker. R96: sitemap entries and page addresses are compared with the product's own normalizer (scope check, fragment, `index.html`) after `www.`, http/https and a trailing slash of the run's host are folded, so a sitemap that lists the page as `http://www.host/page/` lists it; only same-site entries that are not sitemap files (`.xml`, `.xml.gz`) count as listed pages. R97 (and R89a): a sitemap is read only on a 2xx whose body is a sitemap (`urlset` or `sitemapindex`); 404 and 410 are read and list nothing; a 5xx, timeout, connection error, 403 or a body that is no sitemap is missing evidence; a declared sitemap that was never fetched is missing evidence; a sitemap index counts only when every child it names is a declared sitemap that was read (a child beyond the preflight request limit is not), and the entries of an index are never pages. The decision's reason is then exactly `noindex and not listed in the sitemap`, its `evidence_ref` is the page's stored raw HTML, and report replay derives it again from the stored bytes (no approval record). With no sitemap that lists pages, a noindex stays a confirmed failure and a blocker. Public-index targets automatically get `index-policy` and `discovery`. Google or Naver as a target gets `search-crawl-policy`; an AI answer engine (`chatgpt`, `claude`, `perplexity`, targeted when a goal is AI citation) gets `ai-crawler-policy`. A family whose registry entry lists `engines` has units only for those engines among the targets. Naver as a target automatically activates `naver-markup`. `protocol.goals` holds the run's goal ids (`search`, `naver`, `ai_citation`, `brand_facts`, `conversion`, read from the start answers by `scripts/core/goals.mjs`); a family whose registry entry lists `goals` is applicable only when the run has one of them, so the conversion goal activates `conversion-path`. Bare flags cannot turn these families off. A selected URL still gets `fetch-response` if intentionally non-indexed. R94: the families that only matter for indexed pages are marked `public_only` in `check-registry.json` (`index-policy`, `search-crawl-policy`, `ai-crawler-policy`, `discovery`, `naver-markup`, `document-title`, `canonical-consistency`); a page with `public_indexing:false` has no unit, verdict or fix in them, and a title it shares with an indexed page is no duplicate. Its `fetch-response`, render and model-judged units stay. An applicable `public_only` family needs at least one public index target. An unreachable, uncollected or unclear check is `unknown`, never N/A.

`check-registry.json` fixes every family's `verdict_mode` and `na_conditions`. The family-level exclusion and unit N/A condition codes are:

| Family | Allowed condition codes |
|---|---|
| fetch-response | none |
| search-crawl-policy | `no_public_index_target`, `no_target_engine` (whole family only; engines `google`, `naver`) |
| ai-crawler-policy | `no_public_index_target`, `ai_not_target` (whole family only; engines `chatgpt`, `claude`, `perplexity`) |
| index-policy, discovery | `no_public_index_target` (whole family only) |
| render-content | `not_render_dependent` |
| document-title | `not_html` |
| canonical-consistency | `no_representative_url_policy` |
| internal-navigation | `not_link_based` |
| structured-data-truth | `markup_not_used_or_selected` |
| locale-links | `single_locale` |
| naver-markup | `naver_not_target` (whole family only), `not_html` (unit) |
| conversion-path | `conversion_not_goal` (whole family only; goal `conversion`) |
| lcp-field, inp-field, cls-field | `outside_performance_scope` |
| question-coverage | `no_fixed_questions` (whole family only) |
| claim-support | `no_material_claims` |
| answer-clarity | `no_matching_questions` |
| original-value | `simple_contact_or_support` |
| entity-identity | `no_identified_entity` (whole family only) |
| fact-consistency | `no_evaluable_facts` |
| freshness-truth | `no_date_or_currentness` |
| source-transparency | `not_required_for_purpose` |

These codes identify a condition and point to evidence; the reference alone does not verify the evidence's truth. The collecting agent must preserve the actual evidence. A missing approved fact list for `fact-consistency`, or missing field performance data, is `unknown`, not an exclusion.

A host model may declare a unit `not_applicable` only for a per-page content family (`claim-support`, `answer-clarity`, `original-value`, `freshness-truth`, `source-transparency`), only with that family's code from the table, and only citing at least one text span the judge queue offered for the unit. The decision is `{condition,reason,evidence_ref}` with the unit's page evidence as `evidence_ref`, and replay checks the condition and the spans like any model verdict. The report counts these units for each family, apart from the coverage they leave out. The question coverage is derived, not judged: `question-coverage|site` is the number of panel questions that an audited page answers (an answer-clarity `pass` or `partial` naming the question as `measurement.question_id`, counted once per question) over the panel length, `fail` when none is answered, and it exists once the model has judged at least one page against a question. When the home page has no Organization or LocalBusiness JSON-LD and code gave no verdict for `entity-identity|site`, the model judges that unit from the home page's visible text; no JSON-LD alone is never a fail. The model's identity verdict is `pass` only when the cited text shows the target name and at least one other identifying fact that agrees, `partial` (`threshold_half`, q=0.5) when it shows the name only, `fail` when it shows no matching name, and `unknown` when nothing there can be compared. Code's own identity verdicts stay pass or fail.

A model verdict is accepted only if the code can re-derive it from the stored page. A page that shows no text (empty body) gets no model verdict: every verdict on it, `not_applicable` included, becomes `unknown` ("page shows no text"). A verdict that rests on absence (an absence `not_applicable`, a `fail` with `basis: "absence"`, a freshness `fail` labelled `shown` on a page with no date) becomes `unknown` when the queue's spans cover less than `judge_absence_min_coverage` of the page text. An absence `fail` also becomes `unknown` when the page's own text (lines that most audited pages do not repeat) shows a date or names its operator. A date that only site chrome shows (lines on more than half of at least five audited pages) is not the page showing a date: it neither blocks a freshness absence `fail` nor satisfies freshness. A `fail` of an absence family, and a freshness `pass`, that cites only site chrome is `unknown`. Every such `unknown` stays in the denominator as a blank and is counted in the report by reason.

Where the collector's own checks give a verdict instead of leaving a unit blank (each re-derived on replay, and each names its basis in `reason` or `measurement`):

- `render-content`: a page outside the render sample takes the verdict of the rendered page of its page type, only when every rendered page of the type gives the same pass or `not_render_dependent` and that page loads the same external scripts and head assets (stylesheets, preloads) as this one. The page needs body text in its own raw HTML. The reason names the rendered page and the evidence is that page's render record. Otherwise the page stays unknown, including inside the catch-all `pages` type and for list versus detail pages.
- `structured-data-truth`: no JSON-LD, microdata or RDFa is `markup_not_used_or_selected`. For a page's own top-level and `@graph` nodes (never an organization nested in another node): an Organization or LocalBusiness states a name, phone and street address; an Article-family node states a headline (the h1 or title must hold it or be held by it, the shorter being at least four fifths of the longer; only a page with an h1 can contradict it) and a publication date (matched only when year, month and day all match: a full date in the visible text, or the full `datetime` of a `<time>` element that has visible text outside script, template and hidden elements; the matched text and its source, `text` or `time`, are in the measurement, and a `time` element is close to circular because the site writes it and the JSON-LD from one source). The page passes when every stated fact is shown and none is contradicted, fails when a labelled value (or the h1) differs, and is unknown when a stated fact is neither shown nor contradicted or when the JSON-LD states nothing of these kinds. A publisher or author name alone never passes.
- `document-title`: a title that is present, shared with no other audited page, and not only the site's name (host, its first label, `og:site_name`, or the name of a JSON-LD organization) or a word of `generic_titles` (registry thresholds) passes, when at least two pages were audited. One audited page proves no uniqueness, so its title stays unknown. Whether the title describes the page is not established, and the pass says so.
- `fact-consistency`: a page with no labelled, marked-up (JSON-LD) or `tel:` name, phone, address or founding year is `no_evaluable_facts`. The facts the owner confirmed at the start (`protocol.confirmed_facts`, `{key,value}` from the start answers; keys `name`, `phone`, `address`, `year`) are the reference of their type: a page value of that type that meets none of them fails with a problem that names the type, the page value and the confirmed value, and the cross-page vote is not taken for that type. Values are read and compared as in the cross-page comparison, so a mobile `tel:` link beside a labelled landline is no value of the page. A page that states a fact but meets no reference value stays unknown (that is the missing fact list). With no confirmed facts the check is the cross-page comparison alone.
- `conversion-path` (site unit): passes when any audited page, in its raw HTML or its stored render, offers a contact route: a `tel:`, `sms:` or `mailto:` link, a phone number it shows (a labelled one, or an unlabelled one in a dialling shape: a Korean number with its leading 0 or a 15xx/16xx/18xx representative number), a link to a KakaoTalk channel (`pf.kakao.com`), KakaoTalk open chat (`open.kakao.com`), Naver TalkTalk (`talk.naver.com`) or Naver booking (`booking.naver.com`, `m.booking.naver.com`), or a link within the site whose text or path names 문의, 상담, 예약, contact, inquiry, booking or reservation. The first route of each kind is in the measurement. With none found it fails, unless `render-content` gives any page `unknown` or a pass after rendering, or gives no verdict at all: then it is `unknown` (`chrome_missing` when the run had no Chrome). Phone numbers are not compared with facts here.
- `canonical-consistency`: no canonical and no redirect is `no_representative_url_policy`; no canonical and exactly one redirect hop passes; both only when no other audited page shares the page's title or body text, otherwise unknown.
- `discovery`: a page neither listed in a read sitemap nor linked, and not provable as an orphan (crawl cut short, unread sitemap index, script-built links, one page), is unknown; its measurement says which of these applies. Being selected for the audit is not discoverability.
- `entity-identity`: a page is a separate branch when every Organization node on it is a sub-organization (`parentOrganization`, `branchOf` or a branch word in its name), or when a sub-organization's own `url` (or an `@id` that is the page address) is this page, or the page names it as `about` or `mainEntity` and names no other sub-organization there. A sub-organization whose `url` or `@id` is the site home (with or without a fragment) never makes the home page a branch page. Otherwise, on a page that also has a node that is not a sub-organization, only that node speaks for the page.

`question_panel` contains fixed `{id,question,category,goal?}` entries. Category is `brand` or `non_brand`; IDs are unique. `question-coverage` and `answer-clarity` require a nonempty panel. A partial `question-coverage` ratio uses the fixed panel length as its denominator. A question counts as answered once when some audited page's pass or partial `answer-clarity` verdict names it as `measurement.question_id` or in `measurement.also_question_ids` (a page may answer several questions); text of site chrome that answers a question counts through the page whose verdict cites it. A question no page answers is reported as not found within the pages read, never as missing from the site.

Each page type has a stable ID and an array of `{original,normalized}` HTTP(S) URLs. Normalized URLs must be unique across types. Unit keys are deterministic:

| Unit scope | Key | Examples |
|---|---|---|
| URL | `family_id\|normalized_url` | `index-policy\|https://example.com/` |
| Engine and URL | `family_id\|engine\|normalized_url` | `search-crawl-policy\|google\|https://example.com/` |
| Origin | `family_id\|origin` | `lcp-field\|https://example.com` |
| Site | `family_id\|site` | `question-coverage\|site` |

`CheckObservation.check_id` equals its registered `family_id` in this evaluation version. Multiple modules can submit the same unit and verdict; it counts once. An unregistered unit or conflicting verdict is a contract error. For `pass` and `fail`, `q` is exactly 1 and 0. For `unknown` and `not_applicable`, `q` and `partial_basis` are null. Unit N/A also requires `applicability_decision:{condition,reason,evidence_ref}`; core policy units and fixed site/public units cannot use N/A. A confirmed noindex or a blocked Google or Naver crawler is `fail`, `q=0`, and a blocker. A blocked AI search crawler (`ai-crawler-policy`) is a `fail` that costs score but neither blocks nor counts as an unknown core unit. A robots.txt answered with a 4xx status other than 429 allows every path (RFC 9309 2.3.1.3) for both crawler families; 429, 5xx or no answer gives no verdict, so the unit stays `unknown`. Missing policy evidence is `unknown`.

The binary families (`verdict_mode: "binary"` in the registry) do not accept partial; their applicable units use pass, fail or unknown. Optional units may use N/A only under the allowed condition decision above. `lcp-field`, `inp-field`, `cls-field`, `answer-clarity`, `original-value` and `entity-identity` accept `partial_basis:{kind:"threshold_half"}` with `q=0.5`. `question-coverage`, `claim-support` and `fact-consistency` accept `partial_basis:{kind:"ratio",numerator:int,denominator:positive_int}` with a matching interior ratio. `question-coverage` also requires denominator equal to the fixed panel length. A CWV verdict requires `measurement:{value,percentile:75,source_type:"field",unit}`; use `ms` for LCP/INP and `unitless` for CLS. An absent field measurement is `unknown`, not N/A.

## Weight and status calculation

`compileUnits(input,profile)` divides positive axis weight by applicable families, then engines, then fixed page types and URLs. Each nonempty page type gets equal share; URLs within a type get equal share. Site and origin units do not multiply with page count. Units marked `not_applicable` are removed and the remaining units in that family are renormalized. If a whole family has no units, it is removed from its axis. If an axis has no applicable families, the remaining positive axes are renormalized to 100. LLMO's P axis has zero weight. A weighted unit is `{key,family_id,engine,url,weight,state,q}`.

`scoreWeighted(units)` returns raw weighted totals `A` (applicable), `M` (measured) and `N` (measured credit), plus one-decimal display percentages: `score=100N/M`, `coverage=100M/A`, `missing_bounds=[100N/A,100(N+A-M)/A]`. `score` is null when `M=0`; `coverage` and `missing_bounds` are null when `A=0`. The bounds are all-missing-fail and all-missing-pass extremes, not a confidence interval. Internal comparisons use raw values with `1e-9` tolerance; display rounds only at the end.

`score(input)` returns ordered `ScoreResult[]` for SEO, optional NEO, AEO, GEO and LLMO. SEO is the default aggregate and is calculated directly, never from the mean of profile scores. Each result includes `{schema_version,evaluation_version,profile,A,M,N,score,coverage,missing_bounds,band,readiness_status,blockers,unknown_core,source_ref}`. Bands are `focus_improvement` (0–59), `major_improvement` (60–79), `additional_improvement` (80–94), and `remaining_review` (95–100). Status precedence is `not_applicable` for `A=0`, then `blocked` for confirmed access/crawl/noindex failures or an explicit severe-fact blocker, then `unverified` for unknown applicable access/index core units, then `provisional` below 80% measured coverage, else `measured`. `measured` means only that this optbrew readiness audit has enough measured coverage by its own rule.

Run a stored input without service access: `node skills/optbrew/scripts/cli.mjs score --input input.json --output score.json`. The output is the same JSON `ScoreResult[]` as the library call. The CLI validates before writing.

## Field Core Web Vitals: thresholds and sources

The `good` and `poor` bounds of `lcp-field`, `inp-field` and `cls-field` sit in `check-registry.json` (`thresholds`), so `evaluation_hash` covers them. Each is judged at the 75th percentile of real page loads: at or below `good` is pass, above `poor` is fail, between them is partial (`q=0.5`, an optbrew rule). Confirmed against the primary pages on 2026-10-17:

| Family | Good | Poor | Unit | Source |
|---|---|---|---|---|
| lcp-field | 2500 | above 4000 | ms | https://web.dev/articles/lcp |
| inp-field | 200 | above 500 | ms | https://web.dev/articles/inp |
| cls-field | 0.1 | above 0.25 | unitless | https://web.dev/articles/cls |

The 75th percentile as the measuring point: https://web.dev/articles/vitals. PageSpeed Insights states the same CLS bands: https://developers.google.com/speed/docs/insights/v5/about.

Field data comes from the user's own Google key and nowhere else. `endpoints.json` holds the two endpoints without a key, and the key is added to a request only at request time, in the `X-Goog-Api-Key` header (Google documents the header as the primary way to send an API key, and warns that a `key` query parameter exposes it in URLs: https://docs.cloud.google.com/docs/authentication/api-keys-use). A value that is not shaped like a Google key (letters, digits, `.`, `_`, `-`, at least 20; AI Studio's AQ. keys have a dot) is not used: the run goes keyless and the stored record says `key_ignored_malformed`. Stored answers and error messages have the key removed. Requests go through the run's transport (direct, or the CONNECT proxy when DNS fails), limited by `cwv_timeout_ms` and `cwv_max_bytes` in `limits.json`: a PSI Lighthouse run can take longer than a page fetch, and its answer is far larger than the page limit allows for.

- CrUX API `records:queryRecord` (POST, `origin` and `formFactor` in the body, p75 at `record.metrics.<name>.percentiles.p75`; a 404 with `chrome ux report data not found` means the origin has no data): https://developer.chrome.com/docs/crux/api and https://developer.chrome.com/docs/crux/guides/crux-api. optbrew asks CrUX for `formFactor: "PHONE"` (valid values `DESKTOP`, `PHONE`, `TABLET`) so that it matches PSI `strategy=mobile`; an origin with no phone data is a CrUX 404 and falls back to PSI.
- PageSpeed Insights API `runPagespeed` (GET, `url`, `strategy`, `category`, `key`): https://developers.google.com/speed/docs/insights/v5/reference/pagespeedapi/runpagespeed. optbrew reads `originLoadingExperience`. Google does not document how PSI scales the CLS percentile, so optbrew divides it by 100 (`PSI_CLS_PERCENTILE_SCALE`) and trusts that only while the first distribution bucket ends at 10; any other first bucket, or none, makes the CLS unknown.

Lab Core Web Vitals (measured by `labCwv` with PerformanceObserver in the page optbrew rendered) are reference only: they are stored in `RunState.lab_cwv`, shown under the run conditions as "reference (not scored)", and never become an observation.
