Skip to content
EZGraph Get started

No EZGraph key required. Free production use. No runtime fee. Bring your own model provider. Support Jev. Run in your own infrastructure. Optional support is available.

8. Deterministic rating

No price in QuoteGraph comes from a model. CoverageNode validates the customer’s selection, builds a rating subject from the facts earlier stages saved, and calls a plain TypeScript rating engine. The results are saved to QuoteNode before it runs. This lesson reads the coverage handler and the engine, then reproduces the replay’s premiums by hand.

The goal

  • Treat coverage selection as a policy boundary with explicit rules.
  • Build rating input only from validated, committed state.
  • Keep every factor and constant in one module with no EZGraph dependency.
  • Seed the presentation stage with exactly the numbers it may show.

The coverage handler

@Tool("select_coverage")
async selectCoverage(input: SelectCoverageInput): Promise<ToolResponse> {
  const state = this.graph.graphState();
  const use = state.nodes.VehicleNode?.vehicle;
  if (!use) {
    return reject("Vehicle details are missing; complete the vehicle stage first.");
  }
  const coverage: CoverageSelection = {
    liability: input.liability,
    collisionDeductible: input.collisionDeductible,
    comprehensiveDeductible: input.comprehensiveDeductible,
    extras: [...new Set(input.extras)],
    startDate: input.startDate,
  };
  const now = quoteNow();
  const error = validateCoverageSelection(coverage, use.ownership, now);
  if (error) return reject(error);
  const rating = buildRatingSubject(state.nodes);
  if ("error" in rating) return reject(rating.error);
  const tiers = RatingEngine.quoteTiers(rating.subject, coverage, now);
  this.saveState({ coverage });
  this.graph.saveNodeState(QuoteNode, { tiers });
  return go(QuoteNode).withMessage(new HumanMessage("Present the quote tiers."));
}

The schema has already restricted liability to three levels, each deductible to 250, 500, 1000, or null, and extras to at most two of rental and roadside. The handler adds everything that depends on other facts: ownership, the calendar, and the earlier stages.

It reads state through this.graph.graphState(), the invocation’s materialized state, and it writes two channels before it returns: its own coverage, and QuoteNode’s tiers. When QuoteNode starts in the same turn, its prompt is filled from those tiers.

Coverage rules

export function validateCoverageSelection(
  coverage: CoverageSelection,
  ownership: VehicleUse["ownership"],
  at: Date,
): string | null {
  const start = parseUtcDate(coverage.startDate);
  if (!start) return "startDate must be a real calendar date in YYYY-MM-DD form.";
  const today = utcDay(at);
  if (start < today) return "The coverage start date cannot be in the past.";
  if (start > addDays(today, 60)) return "The coverage start date must be within the next 60 days.";
  const fullCoverage =
    coverage.collisionDeductible !== null && coverage.comprehensiveDeductible !== null;
  if ((ownership === "finance" || ownership === "lease") && !fullCoverage) {
    return "A financed or leased vehicle requires both collision and comprehensive coverage; the lender mandates full coverage.";
  }
  if (coverage.collisionDeductible !== null && coverage.comprehensiveDeductible === null) {
    return "Collision coverage requires comprehensive coverage as well.";
  }
  return null;
}
Rule Why
the start date is a real date, from today through 60 days out a quote is only valid for a short window
a financed or leased car has both collision and comprehensive the lender owns part of the car
collision requires comprehensive a common underwriting rule; comprehensive alone is allowed

The function returns a sentence, not a code, because the sentence goes straight back to the model. On turn 11 of the replay the customer asks to skip physical-damage coverage. coverage.md tells the model about the lender rule, and it explains the rule without calling the tool. If it had submitted liability-only anyway, this function would have refused it. The deterministic test does exactly that: it submits liability-only for a financed car, and asserts that coverage is unsaved and the reply mentions the lender.

The rating subject

export function buildRatingSubject(
  nodes: QuoteGraphNodes,
): { subject: RatingSubject } | { error: string } {
  const driver = nodes.DriverNode?.driver;
  const use = nodes.VehicleNode?.vehicle;
  const history = nodes.HistoryNode?.history;
  const vehicle = use ? VehicleCatalog.fetch(use.vehicleId) : undefined;
  if (!driver || !use || !vehicle || !history) {
    return { error: "Driver, vehicle, and history details must all be captured before rating." };
  }
  return { subject: { driver, vehicle, use, history } };
}

The subject is assembled only from committed channels, and the vehicle is re-fetched from the catalog by ID. Nothing the model said in conversation can reach the engine except through a handler that validated it.

The rating engine

backend/rating-engine.ts has no EZGraph imports. The monthly premium is:

premium = (liability base + collision part + comprehensive part) × risk factor + extras
Part Value
liability base state-minimum $52; standard $68; premium $89
collision part MSRP ÷ 1000 × 1.1 × deductible factor (omitted when collision is null)
comprehensive part MSRP ÷ 1000 × 0.55 × deductible factor (omitted when comprehensive is null)
deductible factor $250: 1.25; $500: 1.0; $1000: 0.8
extras rental $12; roadside $5 (added after the risk factor)

The risk factor multiplies nine factors and is clamped between 0.5 and 4:

Factor Values
age under 20: 1.9; under 25: 1.5; under 30: 1.2; under 65: 1.0; 65 and over: 1.1
experience fewer than 3 years licensed: 1.15; otherwise 1.0
licence status permit: 1.25; valid: 1.0
vehicle 0.9 + 0.08 × risk group (1–5)
mileage up to 7,500: 0.92; up to 12,000: 1.0; up to 20,000: 1.12; more: 1.28
parking garage 0.95; driveway 1.0; street 1.07
incidents 1 + 0.35 per at-fault accident + 0.15 per violation + 0.10 per not-at-fault accident + 0.08 per comprehensive claim
lapse a lapse in the past year: 1.18
currently insured 0.95

Premiums are rounded to cents. The engine then builds three tiers around the customer’s selection:

Tier Liability Deductibles Extras
saver one step down (premium → standard; otherwise state-minimum) $1000 where the selection has coverage; none where it has none none
selected as chosen as chosen as chosen
shield premium $250 where the selection has coverage; $500 where it has none rental and roadside

A worked example

Here is the replay’s customer: Jamie Rivera, born 12 April 1993 and quoted on 1 June 2027. Ten years licensed with a valid licence; a financed 2019 Camry SE (risk group 2, MSRP $26,000) driven 12,000 miles a year and parked in a driveway; insured, no lapse, and one speeding ticket.

Risk factor. Age 34 → 1.0. Experience, status, mileage, and parking → 1.0. Vehicle → 0.9 + 0.08 × 2 = 1.06. One violation → 1.15. Insured → 0.95. Product: 1.06 × 1.15 × 0.95 = 1.15805.

Selected tier (standard liability, $500/$500, rental):

68 + 26 × 1.1 × 1.0 + 26 × 0.55 × 1.0 = 68 + 28.60 + 14.30 = 110.90
110.90 × 1.15805 = 128.43 → + $12 rental = $140.43
Tier Base before risk × 1.15805 + extras Premium
Saver (state-minimum, $1000/$1000) 52 + 22.88 + 11.44 = 86.32 99.96 $0 $99.96
Selected (standard, $500/$500, rental) 110.90 128.43 $12 $140.43
Shield (premium, $250/$250, rental + roadside) 89 + 35.75 + 17.875 = 142.625 165.17 $17 $182.17
Selected after the what-if ($1000/$1000) 68 + 22.88 + 11.44 = 102.32 118.49 $12 $130.49

Those are exactly the numbers in the replay’s turns 13 and 14. The unit tests in quote-graph.spec.ts assert the risk factor to nine decimal places and the $140.43 premium.

Two properties to know

  • Saver and Shield do not depend on the customer’s deductibles, only on whether each coverage exists. That is why the what-if on turn 14 changed only the middle tier.
  • Tiers can tie. A customer who already chose state-minimum liability, $1000 deductibles, and no extras gets a Saver identical to their selection. The unit test’s “saver < selected < shield” holds for its subject, not for every customer. If distinct tiers matter, detect the tie and explain it, or build Saver differently.
Demo rates, not a rating plan

The factors are invented to make a readable, testable example. A real rating engine is filed per state, versioned, and audited. The pattern is what carries over: a pure function of validated inputs and a date, called from one place, and tested without a model.

Why it is written this way

The quote changes whenever any accepted fact changes, so the relationship between facts and price must be reproducible. A pure engine that reads only committed state makes that relationship a unit test, not a conversation to replay. It also means QuoteNode’s model can explain prices fluently without owning any pricing logic, because the prices arrive in its prompt already calculated.

Common mistakes

  • Letting the model calculate or adjust a premium. Put every number in code and fill it in.
  • Rating from conversation text. Build the subject only from validated node state.
  • Validation spread across nodes. Keep coverage rules in one function that every caller uses; adjust_quote calls the same one.
  • Returning error codes to a model. Return a sentence it can relay.
  • Assuming tiers are always distinct. Check the edge cases your tier rules create.

Next

Continue to 9. Revisions, direct responses, and acceptance.