AI visibility teams often start with a freshness rule buried inside a query, a notebook, or a dashboard formula. That works until the provider mix changes, a new reporting use case appears, or somebody needs to explain why yesterday's score moved.
A better pattern is to treat freshness as a versioned policy. Store the assumptions in configuration, validate them at startup, and publish the policy version beside every reported score.
This article shows one implementation pattern using YAML and TypeScript. All thresholds are illustrative, not universal benchmarks. Tune them to your provider volatility, collection cadence, and decision risk.
What the policy must express
A useful policy should answer five questions:
- How quickly does an observation lose weight?
- At what age is it excluded entirely?
- How much recent collection coverage is required?
- Which operational state should downstream systems receive?
- Which policy version produced the score?
Do not collapse all five into one "quality score." Freshness weighting and collection coverage represent different failure modes.
An illustrative YAML policy
policy:
id: ai-visibility-freshness-v1
effective_from: 2026-08-03T00:00:00Z
defaults:
decay:
shape: exponential
half_life_hours: 168
maximum_age_hours: 720
coverage:
window_hours: 168
minimum_ratio: 0.90
states:
current:
maximum_median_age_hours: 48
aging:
maximum_median_age_hours: 168
stale:
maximum_median_age_hours: 720
overrides:
- match:
provider: example-fast-changing-provider
decay:
half_life_hours: 72
maximum_age_hours: 336
- match:
query_class: breaking-news
coverage:
window_hours: 24
minimum_ratio: 0.95
The provider and query names above are placeholders. The value of this structure is not the numbers; it is the explicit separation between decay, exclusion, coverage, and state.
Define a narrow TypeScript model
type DecayShape = "linear" | "exponential" | "tiered";
type FreshnessPolicy = {
id: string;
effectiveFrom: string;
decay: {
shape: DecayShape;
halfLifeHours?: number;
maximumAgeHours: number;
};
coverage: {
windowHours: number;
minimumRatio: number;
};
states: {
currentMaxMedianAgeHours: number;
agingMaxMedianAgeHours: number;
staleMaxMedianAgeHours: number;
};
};
type Observation = {
observedAt: Date;
visibility: 0 | 1;
};
type FreshnessState = "current" | "aging" | "stale" | "suppressed";
Keep the model deliberately small. Provider-specific and query-class overrides can be resolved before this object reaches the scoring function.
Calculate the observation weight
For an exponential policy, the half-life formula is straightforward:
function exponentialWeight(
ageHours: number,
halfLifeHours: number,
maximumAgeHours: number,
): number {
if (ageHours < 0) {
throw new Error("observedAt cannot be in the future");
}
if (ageHours > maximumAgeHours) {
return 0;
}
return Math.pow(0.5, ageHours / halfLifeHours);
}
With a seven-day half-life, a seven-day-old observation receives 0.5 weight and a fourteen-day-old observation receives 0.25 weight. That is arithmetic from the configured formula, not a claim about how quickly any specific AI system changes.
A linear policy can be useful when stakeholders want a simple deadline:
function linearWeight(ageHours: number, maximumAgeHours: number): number {
if (ageHours < 0) {
throw new Error("observedAt cannot be in the future");
}
return Math.max(0, 1 - ageHours / maximumAgeHours);
}
Produce a weighted visibility rate
function weightedVisibility(
observations: Observation[],
now: Date,
policy: FreshnessPolicy,
): number | null {
let weightedMentions = 0;
let totalWeight = 0;
for (const observation of observations) {
const ageHours =
(now.getTime() - observation.observedAt.getTime()) / 3_600_000;
const weight =
policy.decay.shape === "exponential"
? exponentialWeight(
ageHours,
policy.decay.halfLifeHours!,
policy.decay.maximumAgeHours,
)
: linearWeight(ageHours, policy.decay.maximumAgeHours);
weightedMentions += observation.visibility * weight;
totalWeight += weight;
}
return totalWeight === 0 ? null : weightedMentions / totalWeight;
}
Returning null when total weight is zero is important. A missing current measurement must not silently become a zero visibility result.
Keep recent coverage separate
Coverage asks whether the system collected the runs it expected. Freshness asks how much an existing observation should influence the present score.
Track both:
type ScoreEnvelope = {
policyId: string;
computedAt: string;
weightedVisibility: number | null;
recentCoverageRatio: number;
medianAgeHours: number | null;
oldestIncludedAgeHours: number | null;
state: FreshnessState;
};
This envelope gives dashboards and APIs enough context to avoid false precision.
Derive an operational state
function classifyState(
coverageRatio: number,
medianAgeHours: number | null,
policy: FreshnessPolicy,
): FreshnessState {
if (
medianAgeHours === null ||
coverageRatio < policy.coverage.minimumRatio
) {
return "suppressed";
}
if (medianAgeHours <= policy.states.currentMaxMedianAgeHours) {
return "current";
}
if (medianAgeHours <= policy.states.agingMaxMedianAgeHours) {
return "aging";
}
if (medianAgeHours <= policy.states.staleMaxMedianAgeHours) {
return "stale";
}
return "suppressed";
}
The state should travel with the score. Do not rely on a dashboard color that disappears when data is exported.
Validate policy changes
Configuration is only safer when invalid combinations are rejected. Add startup checks for:
- half-life greater than zero;
- maximum age greater than the half-life;
- coverage ratio between zero and one;
- state age thresholds in increasing order;
- an effective date and immutable policy identifier;
- overrides that match a known provider or query class.
Also store the policy ID in every materialized score. When a policy changes, historical reports can then be reproduced under the original rules.
Test failure scenarios, not only happy paths
A minimal test matrix should include:
- every observation is fresh;
- all observations are beyond maximum age;
- collection coverage falls below the minimum;
- one provider continues while another stops;
- a future timestamp arrives because of clock skew;
- a backfill is delivered late but keeps its real observation time;
- an override shortens the half-life for one query class;
- a policy version changes halfway through a reporting period.
The expected behavior should be visible degradation: lower weights, an aging or stale state, or suppression. The system should never manufacture a confident current score from insufficient recent evidence.
Rollout checklist
- Version the policy in source control or a controlled configuration store.
- Validate it before scoring starts.
- Record observedAt and computedAt separately.
- Preserve raw observations so historical scores can be replayed.
- Publish coverage and age statistics beside the rate.
- Suppress the score when guardrails fail.
- Review overrides when providers or query sets change.
- Document the policy where analysts and downstream developers can find it.
Corank is building measurement workflows for how brands appear across AI search and answer systems. For the conceptual background behind decay shapes and decision horizons, see the companion guide: How to Model Freshness Decay in AI Visibility Monitoring.
The governing principle is simple: an old observation may remain useful, but it should never impersonate a current one. A versioned freshness policy makes that distinction explicit, testable, and reproducible.
Top comments (0)