diff --git a/src/modules/estimateWizard/ai/bannedClaims.js b/src/modules/estimateWizard/ai/bannedClaims.js
new file mode 100644
index 0000000..9f7f924
--- /dev/null
+++ b/src/modules/estimateWizard/ai/bannedClaims.js
@@ -0,0 +1,44 @@
+/**
+ * bannedClaims.js — guardrail for AI-generated customer copy (spec §15, §18).
+ *
+ * Every AI explanation MUST pass validateClaim() before it is shown or stored.
+ * On any violation the caller discards the AI text and uses the deterministic
+ * fallback. This enforces: no guaranteed discount, no guaranteed hail event,
+ * no insurance/legal advice, no manufacturer names (unless brand mode enabled).
+ */
+
+// Manufacturer / brand names that must not appear in customer-facing copy (§3, §15).
+const MANUFACTURER_NAMES = [
+ 'gaf', 'owens corning', 'certainteed', 'malarkey', 'iko', 'tamko',
+ 'atlas', 'decra', 'davinci', 'brava', 'f-wave', 'euroshield', 'boral', 'timberline',
+];
+
+const BANNED_PATTERNS = [
+ { id: 'guaranteed_discount', re: /\b(guarantee[ds]?|guaranteed)\b[^.]*\b(discount|savings|premium)\b/i },
+ { id: 'guaranteed_discount2', re: /\b(discount|savings)\b[^.]*\bguarantee/i },
+ { id: 'will_get_hail', re: /\b(will|going to|guaranteed to)\b[^.]*\b(hail|storm|be hit|get hit)\b/i },
+ { id: 'never_replace', re: /\bnever (have to )?replace\b/i },
+ { id: 'lifetime_no_maintenance', re: /\b(maintenance[- ]free|zero maintenance) (for )?life/i },
+ { id: 'file_a_claim', re: /\b(file|submit) (a|an|your) (insurance )?claim\b/i },
+ { id: 'insurance_advice', re: /\b(you should|we recommend|advise you to) (change|switch|drop|cancel) (your )?insurance\b/i },
+];
+
+/**
+ * @returns {{ ok: boolean, violations: string[] }}
+ */
+export function validateClaim(text = '', { allowBrands = false } = {}) {
+ const violations = [];
+ const lower = text.toLowerCase();
+
+ for (const { id, re } of BANNED_PATTERNS) {
+ if (re.test(text)) violations.push(id);
+ }
+ if (!allowBrands) {
+ for (const name of MANUFACTURER_NAMES) {
+ if (lower.includes(name)) { violations.push(`manufacturer:${name}`); break; }
+ }
+ }
+ return { ok: violations.length === 0, violations };
+}
+
+export const BANNED_CLAIM_IDS = BANNED_PATTERNS.map((p) => p.id);
diff --git a/src/modules/estimateWizard/ai/explain.js b/src/modules/estimateWizard/ai/explain.js
new file mode 100644
index 0000000..4703337
--- /dev/null
+++ b/src/modules/estimateWizard/ai/explain.js
@@ -0,0 +1,122 @@
+/**
+ * explain.js — AI recommendation explanation + rep brief (spec §9).
+ *
+ * Design:
+ * - The deterministic engine already chose the package and the reasons. The AI
+ * only turns them into warm plain-language copy; it cannot change the result.
+ * - Reuses the app's existing Groq integration (config.groqApiKey). With no valid
+ * key (config.isDemoMode) it skips the API and uses a deterministic fallback.
+ * - EVERY AI output passes validateClaim(); on any violation we discard the AI
+ * text and use the safe fallback (spec §15, §18).
+ */
+import Groq from 'groq-sdk';
+import { config } from '../../../config/env';
+import { SYSTEM_PROMPT } from './knowledgeBase';
+import { validateClaim } from './bannedClaims';
+
+const AI_MODEL = 'openai/gpt-oss-120b';
+
+const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
+
+function fmtUSD(n) { return `$${Math.round(n).toLocaleString('en-US')}`; }
+
+// ── Deterministic fallback (also the safety net if AI is unavailable/invalid) ──
+
+function fallbackExplanation({ inputs, recommendation, valueModel }) {
+ const type = recommendation.selectedPackage;
+ const label = PKG_LABEL[type];
+ const reasons = recommendation.reasons;
+
+ const row = valueModel.rows.find((r) => r.type === type);
+ let savingsLine = '';
+ if (valueModel.premiumKnown && row?.savings) {
+ savingsLine = ` Over 10 years, an illustrative savings scenario ranges from ${fmtUSD(row.savings.low)} to ${fmtUSD(row.savings.high)} — this is only an estimate and must be confirmed with your insurance carrier.`;
+ }
+
+ const customerExplanation =
+ `Based on your answers, the ${label} option appears to be the strongest fit for your home and goals.` +
+ ` ${reasons[0] || ''}${savingsLine} We still recommend reviewing all three options with your roofing consultant, since final pricing, roof measurement, ventilation, and insurance eligibility must be verified.`;
+
+ return { customerExplanation, reasons, repBrief: buildRepBrief({ inputs, recommendation }), aiUsed: false, guardrail: { ok: true, violations: [] } };
+}
+
+// Rep brief is deterministic regardless of AI (spec §9) — concise sales prep.
+function buildRepBrief({ inputs, recommendation }) {
+ const parts = [];
+ parts.push(`Recommended: ${PKG_LABEL[recommendation.selectedPackage]} (${recommendation.modelVersion}).`);
+ if (inputs.ownershipHorizon) parts.push(`Ownership ${inputs.ownershipHorizon} yrs.`);
+ if (inputs.budgetPreference) parts.push(`Budget pref: ${inputs.budgetPreference}.`);
+ if (inputs.premiumUnknown || !inputs.premiumAmount) parts.push('Premium skipped — bring savings-scenario explanation.');
+ else parts.push(`Premium ~$${inputs.premiumAmount}/yr.`);
+ if (inputs.atticConcern && inputs.atticConcern !== 'none') parts.push('Mentioned attic/ventilation — bring ventilation guidance.');
+ if (inputs.pestConcern === 'yes') parts.push('Pest concern — discuss critter guard.');
+ if (inputs.issueType === 'leak') parts.push('Active leak — prioritize inspection.');
+ if (inputs.hoaConstraints === 'yes') parts.push('HOA constraints — verify approval before final material.');
+ if (inputs.financingInterest === 'yes') parts.push('Interested in financing — prepare options.');
+ if (recommendation.budgetGuardApplied) parts.push('Budget guard applied (did not upsell to Best over stated low-cost preference).');
+ if (recommendation.uncertaintyFlags.length) parts.push(`Data gaps: ${recommendation.uncertaintyFlags.join(', ')}.`);
+ return parts.join(' ');
+}
+
+// ── AI path ───────────────────────────────────────────────────────────────────
+
+function buildUserPrompt({ inputs, hailSnapshot, recommendation, valueModel }) {
+ const type = recommendation.selectedPackage;
+ const row = valueModel.rows.find((r) => r.type === type);
+ return `Write a 3-4 sentence plain-language explanation for the homeowner of why the ${PKG_LABEL[type]} roof option is highlighted, then a one-line internal rep brief.
+
+Context (do not contradict; do not add numbers not here):
+- Recommended option: ${PKG_LABEL[type]}
+- Deterministic reasons: ${recommendation.reasons.join(' | ')}
+- Ownership horizon: ${inputs.ownershipHorizon || 'unknown'}
+- Budget preference: ${inputs.budgetPreference || 'unknown'}
+- Roof age: ${inputs.roofAgeYears ?? 'unknown'} years
+- Area hail exposure (historical): ${hailSnapshot?.exposureTier || 'unknown'}
+- Annual premium: ${inputs.premiumUnknown || !inputs.premiumAmount ? 'not provided' : '$' + inputs.premiumAmount}
+- 10-yr illustrative savings (base, ${type}): ${row?.savings ? '$' + row.savings.base : 'n/a'}
+
+Respond ONLY as JSON: {"customerExplanation": "...", "repBrief": "..."}`;
+}
+
+export async function generateExplanation(ctx) {
+ // No valid key → deterministic mode (still fully functional for the demo).
+ if (config.isDemoMode(config.groqApiKey)) {
+ return fallbackExplanation(ctx);
+ }
+
+ try {
+ const groq = new Groq({ apiKey: config.groqApiKey, dangerouslyAllowBrowser: true });
+ const completion = await groq.chat.completions.create({
+ model: AI_MODEL,
+ temperature: 0.3,
+ max_tokens: 400,
+ response_format: { type: 'json_object' },
+ messages: [
+ { role: 'system', content: SYSTEM_PROMPT },
+ { role: 'user', content: buildUserPrompt(ctx) },
+ ],
+ });
+
+ const raw = completion.choices?.[0]?.message?.content || '';
+ let parsed;
+ try { parsed = JSON.parse(raw); } catch { return fallbackExplanation(ctx); }
+
+ const customerExplanation = String(parsed.customerExplanation || '').trim();
+ const repBriefAI = String(parsed.repBrief || '').trim();
+
+ // Guardrail: validate the customer-facing text. Any violation → safe fallback.
+ const guardrail = validateClaim(customerExplanation);
+ if (!guardrail.ok || customerExplanation.length < 20) {
+ const fb = fallbackExplanation(ctx);
+ return { ...fb, guardrail, blockedAiText: customerExplanation || null };
+ }
+
+ // Rep brief stays deterministic + optional AI sentence (also validated).
+ const repValidated = validateClaim(repBriefAI).ok ? repBriefAI : '';
+ const repBrief = [buildRepBrief(ctx), repValidated].filter(Boolean).join(' ');
+
+ return { customerExplanation, reasons: ctx.recommendation.reasons, repBrief, aiUsed: true, guardrail };
+ } catch {
+ return fallbackExplanation(ctx);
+ }
+}
diff --git a/src/modules/estimateWizard/ai/knowledgeBase.js b/src/modules/estimateWizard/ai/knowledgeBase.js
new file mode 100644
index 0000000..27ad14a
--- /dev/null
+++ b/src/modules/estimateWizard/ai/knowledgeBase.js
@@ -0,0 +1,34 @@
+/**
+ * knowledgeBase.js — approved content blocks for AI grounding (spec §9, §15).
+ * The AI must ground answers in these; unknown questions route to the rep. Keeping
+ * the approved facts in one place makes the guardrails auditable.
+ */
+
+export const APPROVED_FACTS = {
+ class4: 'Class 4 means the roofing material has passed a higher impact-resistance test (UL 2218). Whether it affects an insurance premium is decided by the carrier, not the contractor.',
+ hailFraming: 'Historical hail data describes the surrounding AREA over time. It is not a forecast or a guarantee that a specific home will be hit.',
+ insuranceDisclaimer: 'Any insurance premium savings are illustrative and must be confirmed with the homeowner’s insurance carrier or licensed agent. Discounts depend on state, carrier, policy, roof class, and documentation.',
+ serviceLife: 'Premium systems are designed for longer service life, but actual lifespan depends on installation, ventilation, maintenance, climate, and product. Avoid promises like “never replace again.”',
+ preliminary: 'Every wizard estimate is preliminary until a roof measurement and contractor inspection verify it.',
+ neutrality: 'Describe systems by generic category (architectural shingle, Class 4 impact-rated shingle, composite/synthetic or stone-coated steel). Do not name manufacturers.',
+};
+
+// Compact system prompt for the AI explainer (kept short for fast demo responses).
+export const SYSTEM_PROMPT = `You are LynkedUp Pro's roofing estimate explainer. You write short, plain-language, HONEST explanations for a homeowner comparing Good/Better/Best roof options.
+
+NON-NEGOTIABLE RULES:
+- Never guarantee an insurance discount or savings. Say savings are illustrative and must be confirmed with their carrier.
+- Never say a home WILL get hail or be hit. Only describe historical AREA exposure.
+- Never give insurance or legal advice. Never tell them to file a claim or change insurance.
+- Never name roofing manufacturers/brands. Use generic categories only.
+- Never promise "never replace again" — say "designed for longer service life."
+- Do not invent measurements, prices, or discounts beyond what you are given.
+- The recommended option is decided by a deterministic engine; you EXPLAIN it, you do not change it.
+
+Approved facts you may use:
+- ${APPROVED_FACTS.class4}
+- ${APPROVED_FACTS.hailFraming}
+- ${APPROVED_FACTS.insuranceDisclaimer}
+- ${APPROVED_FACTS.serviceLife}
+
+Tone: warm, clear, non-pushy. No hype.`;
diff --git a/src/modules/estimateWizard/components/TenYearValueChart.jsx b/src/modules/estimateWizard/components/TenYearValueChart.jsx
new file mode 100644
index 0000000..ab6b088
--- /dev/null
+++ b/src/modules/estimateWizard/components/TenYearValueChart.jsx
@@ -0,0 +1,52 @@
+/**
+ * TenYearValueChart — upfront vs illustrative 10-yr savings, per option (spec §10).
+ * Savings bars only render when a premium was provided and conservative mode is off.
+ */
+import React from 'react';
+import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend, Cell } from 'recharts';
+
+const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
+const BAR_COLOR = { GOOD: '#a1a1aa', BETTER: '#3b82f6', BEST: '#fda913' };
+
+const TenYearValueChart = ({ valueModel }) => {
+ const data = valueModel.rows.map((r) => ({
+ name: PKG_LABEL[r.type],
+ type: r.type,
+ Upfront: r.price.base,
+ 'Est. 10-yr savings': r.savings ? r.savings.base : 0,
+ }));
+
+ const fmt = (n) => `$${(n / 1000).toFixed(0)}k`;
+
+ return (
+
+
10-year value comparison
+
+
+
+
+
+ `$${Number(v).toLocaleString('en-US')}`}
+ contentStyle={{ borderRadius: 12, border: '1px solid #e4e4e7', fontSize: 12 }}
+ />
+
+
+ {data.map((d) => | )}
+
+ {valueModel.premiumKnown && !valueModel.conservativeMode && (
+
+ )}
+
+
+
+
+ {valueModel.premiumKnown
+ ? 'Savings shown are illustrative and depend on carrier approval, policy terms, and documentation.'
+ : 'Add your insurance premium earlier in the wizard to see an illustrative 10-year savings scenario.'}
+
+
+ );
+};
+
+export default TenYearValueChart;
diff --git a/src/modules/estimateWizard/components/WizardShell.jsx b/src/modules/estimateWizard/components/WizardShell.jsx
index 71033bc..fddf82c 100644
--- a/src/modules/estimateWizard/components/WizardShell.jsx
+++ b/src/modules/estimateWizard/components/WizardShell.jsx
@@ -18,6 +18,7 @@ import PremiumStep from './steps/PremiumStep';
import HailRiskStep from './steps/HailRiskStep';
import OptionsStep from './steps/OptionsStep';
import AddonsStep from './steps/AddonsStep';
+import RecommendationStep from './steps/RecommendationStep';
import PlaceholderStep from './steps/PlaceholderStep';
// Step registry — later phases swap PlaceholderStep entries for real components.
@@ -28,7 +29,7 @@ const STEP_COMPONENTS = {
hailRisk: HailRiskStep,
options: OptionsStep,
addons: AddonsStep,
- recommendation: (props) => ,
+ recommendation: RecommendationStep,
report: (props) => ,
};
diff --git a/src/modules/estimateWizard/components/steps/RecommendationStep.jsx b/src/modules/estimateWizard/components/steps/RecommendationStep.jsx
new file mode 100644
index 0000000..0778b93
--- /dev/null
+++ b/src/modules/estimateWizard/components/steps/RecommendationStep.jsx
@@ -0,0 +1,127 @@
+/**
+ * RecommendationStep — Screen 8 "AI recommendation" (spec §9, §10).
+ *
+ * Flow:
+ * 1. Deterministic scoring engine picks the highlight + reasons (logged).
+ * 2. 10-year value model computes the savings/lifecycle comparison.
+ * 3. AI turns the reasons into plain-language copy (guardrailed); falls back to
+ * deterministic text if AI is unavailable or trips a banned-claim check.
+ * The recommendation record is frozen into the session (spec §12 recommendations).
+ */
+import React, { useEffect, useMemo, useState } from 'react';
+import { Sparkles, Loader2, Check, ShieldCheck } from 'lucide-react';
+import { useEstimateWizard } from '../../EstimateWizardContext';
+import { score } from '../../engine/scoringEngine';
+import { computeTenYearValue } from '../../engine/valueModel';
+import { generateExplanation } from '../../ai/explain';
+import GoodBetterBestCards from '../GoodBetterBestCards';
+import TenYearValueChart from '../TenYearValueChart';
+
+const PKG_LABEL = { GOOD: 'Good', BETTER: 'Better', BEST: 'Best' };
+
+const RecommendationStep = () => {
+ const { inputs, session, patchSession, tenantConfig, logEvent } = useEstimateWizard();
+
+ // Deterministic — recomputed from current answers, stable across renders.
+ const recommendation = useMemo(
+ () => score({ inputs, hailSnapshot: session.hailSnapshot, tenantConfig }),
+ [inputs, session.hailSnapshot, tenantConfig]
+ );
+ const value = useMemo(
+ () => computeTenYearValue({ inputs, tenantConfig }),
+ [inputs, tenantConfig]
+ );
+
+ const [explanation, setExplanation] = useState(session.recommendation?.explanation || null);
+ const [loadingAI, setLoadingAI] = useState(true);
+
+ // Freeze deterministic record + log it (reproducible audit, spec §18).
+ useEffect(() => {
+ patchSession({ recommendation: { ...recommendation, value } });
+ logEvent('recommendation_scored', {
+ modelVersion: recommendation.modelVersion,
+ selectedPackage: recommendation.selectedPackage,
+ scores: Object.fromEntries(Object.entries(recommendation.scores).map(([k, v]) => [k, v.total])),
+ uncertaintyFlags: recommendation.uncertaintyFlags,
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [recommendation, value]);
+
+ // AI explanation (async, guardrailed).
+ useEffect(() => {
+ let cancelled = false;
+ setLoadingAI(true);
+ generateExplanation({ inputs, hailSnapshot: session.hailSnapshot, recommendation, valueModel: value, tenantConfig })
+ .then((res) => {
+ if (cancelled) return;
+ setExplanation(res);
+ patchSession({ recommendation: { ...recommendation, value, explanation: res } });
+ logEvent('ai_explanation_generated', { aiUsed: res.aiUsed, guardrailOk: res.guardrail?.ok });
+ });
+ return () => { cancelled = true; };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [recommendation, value]);
+
+ // Lock the highlighted option in as the customer's selection if they hadn't chosen.
+ useEffect(() => {
+ if (!session.selectedPackage) patchSession({ selectedPackage: recommendation.selectedPackage });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const highlight = recommendation.selectedPackage;
+
+ return (
+
+
{ patchSession({ selectedPackage: t }); logEvent('package_selected', { package: t, after: 'recommendation' }); }}
+ />
+
+ {/* AI explanation */}
+
+
+
+
+ Why we highlighted {PKG_LABEL[highlight]}
+
+ {explanation && (
+
+ {explanation.aiUsed ? 'AI-assisted' : 'Reviewed'}
+
+ )}
+
+
+ {loadingAI && !explanation ? (
+
+ Preparing your explanation…
+
+ ) : (
+ <>
+
+ {explanation?.customerExplanation}
+
+
+ {recommendation.reasons.map((r) => (
+ -
+ {r}
+
+ ))}
+
+ >
+ )}
+
+
+
+
+
+ This recommendation considers your stated budget and goals — all three options stay available.
+ Insurance savings are illustrative and must be confirmed with your carrier.
+
+
+ );
+};
+
+export default RecommendationStep;
diff --git a/src/modules/estimateWizard/engine/scoringEngine.js b/src/modules/estimateWizard/engine/scoringEngine.js
new file mode 100644
index 0000000..b6c088f
--- /dev/null
+++ b/src/modules/estimateWizard/engine/scoringEngine.js
@@ -0,0 +1,163 @@
+/**
+ * scoringEngine.js — deterministic Good/Better/Best recommendation (spec §9).
+ *
+ * Contract (spec §9, §18):
+ * - Deterministic & reproducible: same inputs + same weights ⇒ same scores.
+ * - Logged: returns the full per-factor breakdown so it can be audited.
+ * - AI may EXPLAIN the result but must never silently change it.
+ * - Never always recommends Best — budget preference + uncertainty penalties guard
+ * against over-recommendation (spec §15 fairness).
+ *
+ * Pure function. No I/O, no randomness, no Date.
+ */
+
+const TYPES = ['GOOD', 'BETTER', 'BEST'];
+export const SCORING_MODEL_VERSION = 'scoring-v1';
+
+// ── Per-factor sub-scores (0..1) by package type ─────────────────────────────
+
+function ownershipFactor(horizon) {
+ switch (horizon) {
+ case '1-5': return { GOOD: 1.0, BETTER: 0.6, BEST: 0.3 };
+ case '6-15': return { GOOD: 0.5, BETTER: 1.0, BEST: 0.7 };
+ case '16+': return { GOOD: 0.3, BETTER: 0.7, BEST: 1.0 };
+ default: return { GOOD: 0.6, BETTER: 0.7, BEST: 0.6 };
+ }
+}
+
+function hailFactor(exposureTier) {
+ switch (exposureTier) {
+ case 'low': return { GOOD: 1.0, BETTER: 0.7, BEST: 0.5 };
+ case 'moderate': return { GOOD: 0.5, BETTER: 1.0, BEST: 0.8 };
+ case 'high': return { GOOD: 0.2, BETTER: 0.8, BEST: 1.0 };
+ default: return { GOOD: 0.6, BETTER: 0.7, BEST: 0.6 }; // unknown → neutral
+ }
+}
+
+function budgetFactor(pref) {
+ switch (pref) {
+ case 'lowest': return { GOOD: 1.0, BETTER: 0.5, BEST: 0.2 };
+ case 'balanced': return { GOOD: 0.6, BETTER: 1.0, BEST: 0.6 };
+ case 'longterm': return { GOOD: 0.3, BETTER: 0.7, BEST: 1.0 };
+ default: return { GOOD: 0.7, BETTER: 0.8, BEST: 0.6 };
+ }
+}
+
+function roofAgeFactor(years, issueType) {
+ const urgent = issueType === 'leak';
+ if (years >= 15 || urgent) return { GOOD: 0.5, BETTER: 0.8, BEST: 1.0 };
+ if (years >= 8) return { GOOD: 0.7, BETTER: 1.0, BEST: 0.8 };
+ return { GOOD: 1.0, BETTER: 0.8, BEST: 0.6 };
+}
+
+// Insurance-savings factor scales with premium × each package's discount.
+function insuranceFactor(premium, packages) {
+ if (!premium || premium <= 0) {
+ return { factors: { GOOD: 0.5, BETTER: 0.5, BEST: 0.5 }, known: false };
+ }
+ const savings = {};
+ packages.forEach((p) => { savings[p.type] = premium * ((p.insuranceDiscountPct || 0) / 100); });
+ const max = Math.max(...Object.values(savings), 1);
+ const factors = {};
+ TYPES.forEach((t) => { factors[t] = +(savings[t] / max).toFixed(3); });
+ return { factors, known: true, savings };
+}
+
+function aestheticsFactor(inputs) {
+ const premiumLook = inputs.budgetPreference === 'longterm' || inputs.hoaConstraints === 'yes';
+ return premiumLook
+ ? { GOOD: 0.5, BETTER: 0.8, BEST: 1.0 }
+ : { GOOD: 0.7, BETTER: 0.9, BEST: 1.0 };
+}
+
+// ── Reason synthesis (deterministic, feeds AI + fallback copy) ────────────────
+
+function buildReasons(type, ctx) {
+ const { inputs, hailSnapshot } = ctx;
+ const r = [];
+ if (type === 'BEST') {
+ if (inputs.ownershipHorizon === '16+') r.push('You plan to stay in the home long-term (16+ years), so a longer-life system spreads its cost over more years.');
+ if (hailSnapshot?.exposureTier === 'high') r.push('Your area shows higher historical hail exposure, where the most durable system adds the most protection.');
+ if (inputs.premiumAmount > 0) r.push('Your insurance premium creates a potential savings scenario if your carrier recognizes qualifying impact-resistant materials.');
+ r.push('It carries the strongest lifecycle value case and fewest expected future roof cycles.');
+ } else if (type === 'BETTER') {
+ r.push('It improves hail resistance with a Class 4 impact-rated system at a balanced cost.');
+ if (hailSnapshot?.exposureTier !== 'low') r.push('Given your area’s historical hail activity, the added impact resistance is meaningful.');
+ if (inputs.premiumAmount > 0) r.push('It may qualify for insurance premium consideration where available — confirm eligibility with your carrier.');
+ r.push('It balances stronger protection against premium-system cost.');
+ } else {
+ if (inputs.budgetPreference === 'lowest') r.push('You prioritized the lowest upfront investment.');
+ if (inputs.ownershipHorizon === '1-5') r.push('A shorter ownership horizon makes lowest upfront cost a rational choice.');
+ if (hailSnapshot?.exposureTier === 'low') r.push('Your area shows lower historical hail exposure.');
+ r.push('It uses a quality architectural shingle system that meets code with standard accessories.');
+ }
+ return r.slice(0, 4);
+}
+
+/**
+ * score() — main entry.
+ * @param {object} args { inputs, hailSnapshot, tenantConfig }
+ * @returns recommendation record (spec §12 recommendations)
+ */
+export function score({ inputs, hailSnapshot, tenantConfig }) {
+ const weights = tenantConfig.scoringWeights;
+ const packages = tenantConfig.packages;
+
+ const fOwn = ownershipFactor(inputs.ownershipHorizon);
+ const fHail = hailFactor(hailSnapshot?.exposureTier);
+ const fBudget = budgetFactor(inputs.budgetPreference);
+ const fAge = roofAgeFactor(inputs.roofAgeYears ?? 10, inputs.issueType);
+ const fIns = insuranceFactor(inputs.premiumAmount, packages);
+ const fAes = aestheticsFactor(inputs);
+
+ const uncertaintyFlags = [];
+ if (!inputs.premiumAmount || inputs.premiumUnknown) uncertaintyFlags.push('premium_unknown');
+ if (!hailSnapshot || hailSnapshot.exposureTier == null) uncertaintyFlags.push('hail_unknown');
+ if (inputs.hoaConstraints === 'unsure') uncertaintyFlags.push('hoa_unsure');
+
+ const scores = {};
+ TYPES.forEach((t) => {
+ const factors = {
+ ownershipHorizon: fOwn[t],
+ hailRisk: fHail[t],
+ insuranceSavings: fIns.factors[t],
+ budgetPreference: fBudget[t],
+ roofAge: fAge[t],
+ aesthetics: fAes[t],
+ };
+ let total =
+ factors.ownershipHorizon * weights.ownershipHorizon +
+ factors.hailRisk * weights.hailRisk +
+ factors.insuranceSavings * weights.insuranceSavings +
+ factors.budgetPreference * weights.budgetPreference +
+ factors.roofAge * weights.roofAge +
+ factors.aesthetics * weights.aesthetics;
+
+ // Uncertainty penalty — applied to BEST so missing data never auto-upsells.
+ if (t === 'BEST') total -= 0.04 * uncertaintyFlags.length;
+
+ scores[t] = { total: +total.toFixed(4), factors };
+ });
+
+ // Pick the top-scoring package.
+ let selectedPackage = TYPES.reduce((best, t) => (scores[t].total > scores[best].total ? t : best), 'GOOD');
+
+ // Budget guard (spec §7/§15): a strong "lowest upfront" preference must not be
+ // overridden to BEST unless exposure is high AND the homeowner is long-term.
+ let budgetGuardApplied = false;
+ if (inputs.budgetPreference === 'lowest' && selectedPackage === 'BEST') {
+ const justified = hailSnapshot?.exposureTier === 'high' && inputs.ownershipHorizon === '16+';
+ if (!justified) { selectedPackage = 'BETTER'; budgetGuardApplied = true; }
+ }
+
+ return {
+ modelVersion: SCORING_MODEL_VERSION,
+ weights,
+ scores,
+ selectedPackage,
+ reasons: buildReasons(selectedPackage, { inputs, hailSnapshot }),
+ uncertaintyFlags,
+ budgetGuardApplied,
+ insuranceKnown: fIns.known,
+ };
+}
diff --git a/src/modules/estimateWizard/engine/valueModel.js b/src/modules/estimateWizard/engine/valueModel.js
new file mode 100644
index 0000000..a87f017
--- /dev/null
+++ b/src/modules/estimateWizard/engine/valueModel.js
@@ -0,0 +1,72 @@
+/**
+ * valueModel.js — 10-year value comparison (spec §10).
+ *
+ * Makes premium options understandable WITHOUT pretending savings are guaranteed:
+ * - 10-yr premium savings scenario = annual premium × assumed discount × 10
+ * - shows low/base/high range (carrier discount is never certain)
+ * - upgrade breakeven only when pricing AND discount are known
+ * - lifecycle value delta is labeled a model estimate, not a promise
+ *
+ * Pure function. Honors conservativeMode (spec §18) by suppressing savings claims.
+ */
+import { pricePackage } from './pricing';
+
+const YEARS = 10;
+
+export function computeTenYearValue({ inputs, tenantConfig }) {
+ const { packages, pricing, discountScenarios, conservativeMode } = tenantConfig;
+ const premium = (!inputs.premiumUnknown && inputs.premiumAmount > 0) ? inputs.premiumAmount : null;
+
+ const goodPrice = pricePackage(packages.find((p) => p.type === 'GOOD'), pricing).base;
+
+ const rows = packages.map((pkg) => {
+ const price = pricePackage(pkg, pricing);
+ const scen = discountScenarios[pkg.type] || { low: 0, base: 0, high: 0 };
+
+ // 10-year premium savings scenario (illustrative).
+ const savings = (premium && !conservativeMode)
+ ? {
+ low: Math.round(premium * (scen.low / 100) * YEARS),
+ base: Math.round(premium * (scen.base / 100) * YEARS),
+ high: Math.round(premium * (scen.high / 100) * YEARS),
+ }
+ : null;
+
+ // Upgrade cost vs the Good baseline.
+ const upgradeCost = Math.max(0, price.base - goodPrice);
+
+ // Breakeven (years) — only when both pricing and a positive savings scenario exist.
+ const annualSavingsBase = (premium && !conservativeMode) ? premium * (scen.base / 100) : 0;
+ const breakevenYears = (upgradeCost > 0 && annualSavingsBase > 0)
+ ? +(upgradeCost / annualSavingsBase).toFixed(1)
+ : null;
+
+ // Lifecycle value delta (model estimate): savings + avoided future roof reserve − upgrade cost.
+ // Avoided-reserve proxy: extra service life beyond Good, valued at Good's price prorated.
+ const goodLife = packages.find((p) => p.type === 'GOOD').serviceLifeYears || 28;
+ const extraLife = Math.max(0, (pkg.serviceLifeYears || goodLife) - goodLife);
+ const avoidedReserve = Math.round((extraLife / goodLife) * goodPrice * 0.5);
+ const lifecycleDelta = (savings ? savings.base : 0) + avoidedReserve - upgradeCost;
+
+ return {
+ type: pkg.type,
+ name: pkg.name,
+ price,
+ discountPct: scen.base,
+ serviceLifeYears: pkg.serviceLifeYears,
+ hailResistance: pkg.hailResistance,
+ savings,
+ upgradeCost,
+ breakevenYears,
+ avoidedReserve,
+ lifecycleDelta: Math.round(lifecycleDelta),
+ };
+ });
+
+ return {
+ years: YEARS,
+ premiumKnown: !!premium,
+ conservativeMode: !!conservativeMode,
+ rows,
+ };
+}