feat: regime system — Find Matching + By Regime view

- Pattern Lab: "Find Matching" button per pattern uses GPT-4o-mini to classify against library (merge_as_instance / counter_scenario / new_pattern); shows match badge + confidence + suggested #regime_tag; conditional action buttons (Merge / Save counter / Save new)
- save_pattern_from_run handles action='instance' (appends to historical_instances + updates stats), action='counter' (new pattern with counter_of link, tags parent regime_tag), action='new' (unchanged + regime_tag support)
- useApi.ts: extended useSaveLabPattern type + new useFindMatchingPattern mutation + MatchResult export type
- DB migrations: regime_tag + counter_of columns on custom_patterns
- PatternExplorer: new "By Regime" view groups saved patterns by regime_tag; RegimeCard shows historical instances, hit rate, counter-of link (orange); untagged group at bottom

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-22 20:52:11 +02:00
parent 198341b0c2
commit a435c11246
5 changed files with 438 additions and 47 deletions

View File

@@ -37,6 +37,16 @@ class SavePatternRequest(BaseModel):
category: Optional[str] = None
signal_direction: Optional[str] = None
asset_class: Optional[str] = "indices"
# Regime architecture
action: Optional[str] = "new" # "new" | "instance" | "counter"
target_id: Optional[str] = None # existing pattern id for instance/counter
regime_tag: Optional[str] = None # short generic label (e.g. "Health Crisis")
event_name: Optional[str] = None # human-readable event for historical_instance
class FindMatchingRequest(BaseModel):
run_id: str
pattern_index: int
class InstrumentScanRequest(BaseModel):
@@ -260,6 +270,110 @@ def evaluate_instrument_run(run_id: str):
return {"run_id": run_id, "outcomes": outcomes}
@router.post("/find-matching")
async def find_matching_pattern(req: FindMatchingRequest):
"""Ask GPT-4o-mini if this lab pattern matches / counter-scenario an existing library pattern."""
from services.database import get_custom_patterns
from openai import AsyncOpenAI
openai_key = get_config("openai_api_key") or ""
if not openai_key:
raise HTTPException(400, "OpenAI API key not configured")
run = _get_run(req.run_id)
ai_result = json.loads(run.get("ai_result") or "{}")
patterns = ai_result.get("patterns", [])
if req.pattern_index >= len(patterns):
raise HTTPException(400, "pattern_index out of range")
pat = patterns[req.pattern_index]
# Outcome for this pattern if evaluated
outcome_list: list = []
if run.get("outcome"):
try:
outcome_list = json.loads(run["outcome"]) if isinstance(run["outcome"], str) else run["outcome"]
except Exception:
pass
outcome = next((o for o in outcome_list if o.get("pattern_name") == pat.get("name")), None)
# Build compact library (id, name, category, underlying, direction, description, regime_tag)
library = get_custom_patterns()
if not library:
return {
"recommendation": "new_pattern", "match_id": None, "match_name": None,
"confidence": 100, "reasoning": "Library is empty — save as new pattern.",
"suggested_regime_tag": pat.get("category", ""),
}
lib_compact = []
for p in library:
trades = p.get("suggested_trades") or []
underlying = trades[0].get("underlying", "") if trades else ""
lib_compact.append({
"id": p["id"],
"name": p["name"],
"regime_tag": p.get("regime_tag") or "",
"category": p.get("category") or "",
"underlying": underlying,
"signal_direction": p.get("signal_direction") or "",
"description": (p.get("description") or "")[:150],
})
outcome_str = "not evaluated"
if outcome:
ht = outcome.get("hit_type") or ("FULL HIT" if outcome.get("hit") else "MISS")
outcome_str = f"{ht.upper()} — actual move {outcome.get('actual_move_pct',0):+.1f}%"
prompt = f"""You are a quantitative macro pattern analyst. Determine if a new backtest pattern matches an existing library pattern.
## NEW PATTERN (from Pattern Lab backtest)
Name: {pat.get('name')}
Category: {pat.get('category')}
Description: {pat.get('description')}
Underlying: {pat.get('underlying')}
Strategy: {pat.get('strategy')}
Signal direction: {pat.get('signal_direction')} ({pat.get('expected_direction')})
Expected move: {pat.get('expected_direction','?')}{pat.get('expected_move_pct',0)}%
Backtest outcome: {outcome_str}
## EXISTING LIBRARY PATTERNS
{json.dumps(lib_compact, ensure_ascii=False)}
## DECISION RULES
**merge_as_instance**: Same macro REGIME + same INSTRUMENT + same DIRECTION (both bullish, or both bearish, or both volatility/neutral). The new event is just another historical proof of the same thesis.
**counter_scenario**: Same macro REGIME + same INSTRUMENT + OPPOSITE DIRECTION. Both are valid — they capture different scenarios within the same regime (e.g. pandemic bullish USD vs pandemic bearish USD).
**new_pattern**: Different regime, different instrument, or no meaningful structural match.
CRITICAL: Never merge opposite directions as a single pattern — use counter_scenario instead.
Suggest a short generic regime_tag (2-5 words) capturing the macro theme, not the specific event (e.g. "Health Crisis", "Fed Dovish Pivot", "EM Currency Stress", "Geopolitical Energy Shock").
Return ONLY valid JSON:
{{
"recommendation": "merge_as_instance" | "counter_scenario" | "new_pattern",
"match_id": "existing pattern id or null",
"match_name": "existing pattern name or null",
"confidence": 0-100,
"reasoning": "2-3 sentences explaining the decision",
"suggested_regime_tag": "short generic label"
}}"""
client = AsyncOpenAI(api_key=openai_key)
try:
resp = await client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0.1,
response_format={"type": "json_object"},
timeout=30,
)
return json.loads(resp.choices[0].message.content or "{}")
except Exception as e:
raise HTTPException(500, f"Matching failed: {e}")
@router.post("/save-pattern")
def save_pattern_from_run(req: SavePatternRequest):
"""Promote one AI-suggested pattern (with its outcome) to the pattern library."""
@@ -283,53 +397,85 @@ def save_pattern_from_run(req: SavePatternRequest):
hit = bool(outcome.get("hit")) if outcome else None
actual_move = outcome.get("actual_move_pct") if outcome else None
pat_name = req.name or pat.get("name", "Unnamed Pattern")
pat_name = req.name or pat.get("name", "Unnamed Pattern")
action = req.action or "new"
regime_tag = req.regime_tag or pat.get("category", "")
# Check if same name already exists → update reliability counters
conn = get_conn()
existing = conn.execute(
"SELECT id, backtest_hits, backtest_runs_count FROM custom_patterns WHERE name=? AND source='backtested'",
(pat_name,)
).fetchone()
if existing:
new_hits = (existing["backtest_hits"] or 0) + (1 if hit else 0)
new_runs = (existing["backtest_runs_count"] or 0) + 1
conn.execute("""UPDATE custom_patterns
SET backtest_hits=?, backtest_runs_count=?, updated_at=datetime('now')
WHERE id=?""", (new_hits, new_runs, existing["id"]))
conn.commit()
conn.close()
return {"saved": existing["id"], "action": "updated", "backtest_hits": new_hits, "backtest_runs_count": new_runs}
# New pattern
new_id = f"P_LAB_{uuid.uuid4().hex[:6].upper()}"
historical_instance = {
"date": run["analysis_date"],
"horizon": run["horizon_days"],
"theme": run["theme"],
"hit": hit,
new_instance = {
"date": run["analysis_date"],
"event_name": req.event_name or run["theme"],
"horizon": run["horizon_days"],
"run_id": req.run_id,
"hit": hit,
"hit_type": "full" if hit else "miss",
"actual_move_pct": actual_move,
}
conn = get_conn()
# ── ACTION: merge as historical instance of an existing pattern ────────────
if action == "instance" and req.target_id:
existing = conn.execute("SELECT * FROM custom_patterns WHERE id=?", (req.target_id,)).fetchone()
if not existing:
conn.close()
raise HTTPException(404, "Target pattern not found")
instances = json.loads(existing["historical_instances"] or "[]")
instances.append(new_instance)
new_hits = (existing["backtest_hits"] or 0) + (1 if hit else 0)
new_runs = (existing["backtest_runs_count"] or 0) + 1
conn.execute("""UPDATE custom_patterns SET
historical_instances=?, backtest_hits=?, backtest_runs_count=?,
regime_tag=COALESCE(regime_tag, ?), updated_at=datetime('now')
WHERE id=?""", (json.dumps(instances), new_hits, new_runs, regime_tag, req.target_id))
conn.commit()
conn.close()
return {"saved": req.target_id, "action": "merged_instance",
"backtest_hits": new_hits, "backtest_runs_count": new_runs}
# ── ACTION: counter-scenario (new pattern linked to existing) ─────────────
# Also tag the parent with regime_tag if not already set
if action == "counter" and req.target_id:
conn.execute("""UPDATE custom_patterns SET
regime_tag=COALESCE(NULLIF(regime_tag,''), ?)
WHERE id=?""", (regime_tag, req.target_id))
# ── ACTION: new or counter — check for exact name duplicate first ─────────
name_match = conn.execute(
"SELECT id, backtest_hits, backtest_runs_count FROM custom_patterns WHERE name=? AND source='backtested'",
(pat_name,)
).fetchone()
if name_match and action == "new":
new_hits = (name_match["backtest_hits"] or 0) + (1 if hit else 0)
new_runs = (name_match["backtest_runs_count"] or 0) + 1
conn.execute("""UPDATE custom_patterns SET
backtest_hits=?, backtest_runs_count=?,
regime_tag=COALESCE(NULLIF(regime_tag,''), ?), updated_at=datetime('now')
WHERE id=?""", (new_hits, new_runs, regime_tag, name_match["id"]))
conn.commit()
conn.close()
return {"saved": name_match["id"], "action": "updated",
"backtest_hits": new_hits, "backtest_runs_count": new_runs}
# ── Create new pattern ────────────────────────────────────────────────────
new_id = f"P_LAB_{uuid.uuid4().hex[:6].upper()}"
conn.execute("""INSERT INTO custom_patterns (
id, name, description, triggers, keywords, historical_instances,
suggested_trades, asset_class, expected_move_pct, probability,
horizon_days, source, category, signal_direction,
backtest_hits, backtest_runs_count,
backtest_hits, backtest_runs_count, regime_tag, counter_of,
is_active, taxonomy_path, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,'backtested',?,?,?,?,1,'[]',datetime('now'))""", (
) VALUES (?,?,?,?,?,?,?,?,?,?,?,'backtested',?,?,?,?,?,?,1,'[]',datetime('now'))""", (
new_id,
pat_name,
pat.get("description", ""),
json.dumps([]),
json.dumps([]),
json.dumps([historical_instance]),
json.dumps([new_instance]),
json.dumps([{
"underlying": pat.get("underlying", ""),
"strategy": pat.get("strategy", ""),
"expected_move_pct": pat.get("expected_move_pct", 0),
"asset_class": req.asset_class,
"underlying": pat.get("underlying", ""),
"strategy": pat.get("strategy", ""),
"expected_move_pct": pat.get("expected_move_pct", 0),
"asset_class": req.asset_class,
}]),
req.asset_class or "indices",
pat.get("expected_move_pct", 0),
@@ -339,8 +485,11 @@ def save_pattern_from_run(req: SavePatternRequest):
req.signal_direction or pat.get("signal_direction", ""),
1 if hit else 0,
1,
regime_tag,
req.target_id if action == "counter" else None,
))
conn.commit()
conn.close()
return {"saved": new_id, "action": "created", "backtest_hits": 1 if hit else 0, "backtest_runs_count": 1}
return {"saved": new_id, "action": action,
"backtest_hits": 1 if hit else 0, "backtest_runs_count": 1}

View File

@@ -88,6 +88,9 @@ def init_db():
"ALTER TABLE custom_patterns ADD COLUMN backtest_runs_count INTEGER DEFAULT 0",
# Remove all built-in patterns (no proof of legitimacy)
"DELETE FROM custom_patterns WHERE source = 'builtin'",
# Regime / counter-scenario architecture
"ALTER TABLE custom_patterns ADD COLUMN regime_tag TEXT",
"ALTER TABLE custom_patterns ADD COLUMN counter_of TEXT",
]:
try:
c.execute(_sql)

View File

@@ -996,6 +996,8 @@ export const useSaveLabPattern = () => {
mutationFn: (body: {
run_id: string; pattern_index: number; name?: string;
category?: string; signal_direction?: string; asset_class?: string;
action?: 'new' | 'instance' | 'counter';
target_id?: string; regime_tag?: string; event_name?: string;
}) => api.post('/pattern-lab/save-pattern', body).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['all-patterns'] })
@@ -1004,6 +1006,21 @@ export const useSaveLabPattern = () => {
})
}
export type MatchResult = {
recommendation: 'merge_as_instance' | 'counter_scenario' | 'new_pattern'
match_id?: string
match_name?: string
confidence: number
reasoning: string
suggested_regime_tag?: string
}
export const useFindMatchingPattern = () =>
useMutation({
mutationFn: (body: { run_id: string; pattern_index: number }): Promise<MatchResult> =>
api.post('/pattern-lab/find-matching', body).then(r => r.data),
})
export const useDeleteLabRun = () => {
const qc = useQueryClient()
return useMutation({

View File

@@ -4,6 +4,7 @@ import clsx from 'clsx'
import {
TreePine, Search, ChevronRight, ChevronDown,
TrendingUp, TrendingDown, Zap, BookOpen, ExternalLink, RefreshCw,
Layers, GitMerge, Tag,
} from 'lucide-react'
import {
useAllPatterns,
@@ -58,6 +59,10 @@ interface Pattern {
taxonomy_path?: string[]
ai_quality_score?: number
is_active?: number
regime_tag?: string
counter_of?: string
backtest_hits?: number
backtest_runs_count?: number
}
interface ScoreResult {
@@ -749,9 +754,136 @@ function InstrumentLens({ patterns }: { patterns: Pattern[] }) {
)
}
// ── Regime View ───────────────────────────────────────────────────────────────
function RegimeCard({ pattern, allPatterns }: { pattern: Pattern; allPatterns: Pattern[] }) {
const [open, setOpen] = useState(false)
const isCounter = !!pattern.counter_of
const counterOf = isCounter ? allPatterns.find(p => p.id === pattern.counter_of) : null
const instances = Array.isArray(pattern.historical_instances) ? pattern.historical_instances : []
const hitRate = pattern.backtest_runs_count
? Math.round(((pattern.backtest_hits ?? 0) / pattern.backtest_runs_count) * 100)
: null
return (
<div className={clsx(
'border rounded-lg overflow-hidden transition-colors',
isCounter ? 'border-orange-700/50 bg-orange-900/5' : 'border-slate-700/50 bg-dark-700/30'
)}>
<button className="w-full flex items-center gap-3 p-3 text-left hover:bg-slate-800/30 transition-colors"
onClick={() => setOpen(v => !v)}>
{open ? <ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" /> : <ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />}
{isCounter
? <Layers className="w-4 h-4 text-orange-400 shrink-0" />
: <BookOpen className="w-4 h-4 text-violet-400 shrink-0" />}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-slate-100 truncate">{pattern.name}</span>
{isCounter && (
<span className="text-[10px] border border-orange-600 text-orange-400 rounded px-1.5 py-0.5 shrink-0">
counter of {counterOf?.name ?? pattern.counter_of}
</span>
)}
{pattern.signal_direction && (
<span className={clsx('text-[10px] rounded px-1.5 py-0.5 shrink-0',
pattern.signal_direction === 'up' ? 'bg-emerald-900/40 text-emerald-400' :
pattern.signal_direction === 'down' ? 'bg-red-900/40 text-red-400' : 'bg-slate-700 text-slate-400')}>
{pattern.signal_direction === 'up' ? '↑' : pattern.signal_direction === 'down' ? '↓' : '↕'} {pattern.signal_direction}
</span>
)}
{hitRate !== null && (
<span className={clsx('text-[10px] font-mono shrink-0',
hitRate >= 60 ? 'text-emerald-400' : hitRate >= 40 ? 'text-yellow-400' : 'text-red-400')}>
{hitRate}% ({pattern.backtest_hits}/{pattern.backtest_runs_count})
</span>
)}
</div>
<p className="text-[10px] text-slate-500 truncate mt-0.5">{pattern.description}</p>
</div>
<span className="text-[10px] text-slate-600 shrink-0 font-mono">{instances.length} events</span>
</button>
{open && instances.length > 0 && (
<div className="border-t border-slate-700/40 p-3 pt-2">
<div className="text-[10px] text-slate-500 mb-2 font-semibold uppercase tracking-wide">Historical instances</div>
<div className="flex flex-col gap-1.5">
{instances.map((inst: any, i: number) => (
<div key={i} className="flex items-center gap-3 text-[11px]">
<span className="text-slate-500 font-mono w-24 shrink-0">{inst.date ?? '—'}</span>
<span className="text-slate-300 flex-1 truncate">{inst.event_name ?? inst.event ?? inst.theme ?? '—'}</span>
{inst.hit !== undefined && (
<span className={clsx('shrink-0', inst.hit_type === 'full' || inst.hit === true ? 'text-emerald-400' : inst.hit_type === 'partial' ? 'text-amber-400' : 'text-red-400')}>
{inst.hit_type === 'full' || inst.hit === true ? '✓' : inst.hit_type === 'partial' ? '~' : '✗'}
</span>
)}
</div>
))}
</div>
</div>
)}
</div>
)
}
function RegimeView({ patterns }: { patterns: Pattern[] }) {
const groups = useMemo(() => {
const byRegime: Record<string, Pattern[]> = {}
const untagged: Pattern[] = []
for (const p of patterns) {
if (p.source !== 'backtested' && p.source !== 'user') continue
if (p.regime_tag) {
;(byRegime[p.regime_tag] ??= []).push(p)
} else {
untagged.push(p)
}
}
return { byRegime, untagged }
}, [patterns])
const regimes = Object.entries(groups.byRegime).sort((a, b) => b[1].length - a[1].length)
if (regimes.length === 0 && groups.untagged.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-32 gap-3 text-slate-600">
<Tag className="w-8 h-8" />
<p className="text-sm">No saved patterns yet.</p>
<p className="text-xs">Use "Find Matching" in Pattern Lab to classify and save patterns into regimes.</p>
</div>
)
}
return (
<div className="flex flex-col gap-6">
{regimes.map(([tag, pats]) => (
<div key={tag} className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Tag className="w-3.5 h-3.5 text-violet-400" />
<span className="text-sm font-semibold text-violet-300">#{tag}</span>
<span className="text-xs text-slate-500">{pats.length} pattern{pats.length > 1 ? 's' : ''}</span>
</div>
<div className="flex flex-col gap-2 pl-4 border-l border-violet-900/40">
{pats.map(p => <RegimeCard key={p.id} pattern={p} allPatterns={patterns} />)}
</div>
</div>
))}
{groups.untagged.length > 0 && (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Tag className="w-3.5 h-3.5 text-slate-500" />
<span className="text-sm font-semibold text-slate-500">Untagged</span>
<span className="text-xs text-slate-600">{groups.untagged.length} pattern{groups.untagged.length > 1 ? 's' : ''}</span>
</div>
<div className="flex flex-col gap-2 pl-4 border-l border-slate-700/40">
{groups.untagged.map(p => <RegimeCard key={p.id} pattern={p} allPatterns={patterns} />)}
</div>
</div>
)}
</div>
)
}
// ── Page root ─────────────────────────────────────────────────────────────────
type ViewMode = 'tree' | 'instrument'
type ViewMode = 'tree' | 'instrument' | 'regime'
export default function PatternExplorer() {
const [view, setView] = useState<ViewMode>('tree')
@@ -810,6 +942,18 @@ export default function PatternExplorer() {
<Search className="w-3.5 h-3.5" />
Instrument
</button>
<button
className={clsx(
'flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium transition-colors',
view === 'regime'
? 'bg-violet-600/30 text-violet-300 border border-violet-500/30'
: 'text-slate-400 hover:text-slate-200'
)}
onClick={() => setView('regime')}
>
<Layers className="w-3.5 h-3.5" />
By Regime
</button>
</div>
</div>
@@ -823,6 +967,7 @@ export default function PatternExplorer() {
<>
{view === 'tree' && <TreeView patterns={patterns} />}
{view === 'instrument' && <InstrumentLens patterns={patterns} />}
{view === 'regime' && <RegimeView patterns={patterns} />}
</>
)}
</div>

View File

@@ -3,12 +3,14 @@ import {
useRunPatternLab, useEvaluatePatternLab, useSaveLabPattern,
usePatternLabRuns, useDeleteLabRun,
useInstrumentScan, useEvaluateInstrumentScan,
useFindMatchingPattern, MatchResult,
validateTicker,
} from '../hooks/useApi'
import {
FlaskConical, Play, CheckCircle2, XCircle, MinusCircle,
Trash2, Save, RefreshCw, Search, CalendarDays,
TrendingUp, TrendingDown, Zap, BarChart2, ScanLine, DollarSign,
GitMerge, Layers, Plus,
} from 'lucide-react'
import clsx from 'clsx'
import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments'
@@ -255,6 +257,7 @@ export default function PatternLab() {
const { mutate: deleteRun } = useDeleteLabRun()
const { mutateAsync: runInstScan, isPending: scanning } = useInstrumentScan()
const { mutateAsync: evaluateInst, isPending: evalInst } = useEvaluateInstrumentScan()
const { mutateAsync: findMatching } = useFindMatchingPattern()
const runs: any[] = runsData ?? []
@@ -283,9 +286,11 @@ export default function PatternLab() {
const [instSavedIdx, setInstSavedIdx] = useState<Set<number>>(new Set())
// ── Current run state (events mode)
const [activeRun, setActiveRun] = useState<any | null>(null)
const [savedIdx, setSavedIdx] = useState<Set<number>>(new Set())
const [toast, setToast] = useState<string | null>(null)
const [activeRun, setActiveRun] = useState<any | null>(null)
const [savedIdx, setSavedIdx] = useState<Set<number>>(new Set())
const [toast, setToast] = useState<string | null>(null)
const [matchResults, setMatchResults] = useState<Record<string, MatchResult>>({})
const [matchLoading, setMatchLoading] = useState<Record<string, boolean>>({})
const filteredPresets = useMemo(() => PRESETS.filter(p => {
if (yearFilter && p.year !== yearFilter) return false
@@ -338,7 +343,28 @@ export default function PatternLab() {
}
}
const handleSave = async (idx: number) => {
const matchKey = (idx: number) => `${activeRun?.run_id}-${idx}`
const handleFindMatching = async (idx: number) => {
if (!activeRun?.run_id) return
const key = matchKey(idx)
setMatchLoading(prev => ({ ...prev, [key]: true }))
try {
const result = await findMatching({ run_id: activeRun.run_id, pattern_index: idx })
setMatchResults(prev => ({ ...prev, [key]: result }))
} catch (e: any) {
showToast(`Find matching failed: ${e?.response?.data?.detail ?? e?.message}`)
} finally {
setMatchLoading(prev => ({ ...prev, [key]: false }))
}
}
const handleSave = async (
idx: number,
action: 'new' | 'instance' | 'counter' = 'new',
target_id?: string,
regime_tag?: string,
) => {
if (!activeRun?.run_id) return
const pat = activeRun.ai_result?.patterns?.[idx]
if (!pat) return
@@ -349,9 +375,13 @@ export default function PatternLab() {
name: pat.name,
category: pat.category,
signal_direction: pat.signal_direction,
action,
target_id,
regime_tag,
})
setSavedIdx(prev => new Set([...prev, idx]))
showToast(`"${pat.name}" saved to Pattern Library`)
const actionLabel = action === 'instance' ? 'merged as instance' : action === 'counter' ? 'saved as counter-scenario' : 'saved'
showToast(`"${pat.name}" ${actionLabel}`)
} catch (e: any) {
showToast(`Save failed: ${e?.response?.data?.detail ?? e?.message}`)
}
@@ -853,18 +883,65 @@ export default function PatternLab() {
{out && <OutcomeRow out={out} />}
</div>
{/* Save button */}
<div className="flex-shrink-0">
{/* Save / Find Matching buttons */}
<div className="flex-shrink-0 flex flex-col items-end gap-2">
{isSaved ? (
<span className="flex items-center gap-1 text-emerald-400 text-xs">
<CheckCircle2 className="w-3.5 h-3.5" /> Saved
</span>
) : (
<button onClick={() => handleSave(idx)}
className="flex items-center gap-1.5 border border-slate-600 hover:border-violet-500 hover:text-violet-300 text-slate-400 px-2.5 py-1.5 rounded text-xs transition-colors">
<Save className="w-3 h-3" /> Save pattern
</button>
)}
) : (() => {
const key = matchKey(idx)
const mr = matchResults[key]
const loading = matchLoading[key]
if (mr) {
const isInstance = mr.recommendation === 'merge_as_instance'
const isCounter = mr.recommendation === 'counter_scenario'
const badgeColor = isInstance ? 'text-emerald-400 border-emerald-600' : isCounter ? 'text-orange-400 border-orange-600' : 'text-slate-400 border-slate-600'
return (
<div className="flex flex-col items-end gap-1.5">
<div className={`text-[10px] border rounded px-1.5 py-0.5 ${badgeColor}`}>
{isInstance ? <><GitMerge className="w-3 h-3 inline mr-1" />Match: {mr.match_name} ({mr.confidence}%)</>
: isCounter ? <><Layers className="w-3 h-3 inline mr-1" />Counter of: {mr.match_name} ({mr.confidence}%)</>
: <><Plus className="w-3 h-3 inline mr-1" />New pattern</>}
</div>
{mr.suggested_regime_tag && (
<div className="text-[10px] text-violet-400 font-mono">#{mr.suggested_regime_tag}</div>
)}
<div className="flex gap-1.5">
{isInstance && mr.match_id && (
<button onClick={() => handleSave(idx, 'instance', mr.match_id, mr.suggested_regime_tag)}
className="flex items-center gap-1 border border-emerald-600 hover:border-emerald-400 text-emerald-400 px-2 py-1 rounded text-[10px] transition-colors">
<GitMerge className="w-3 h-3" /> Merge
</button>
)}
{isCounter && mr.match_id && (
<button onClick={() => handleSave(idx, 'counter', mr.match_id, mr.suggested_regime_tag)}
className="flex items-center gap-1 border border-orange-600 hover:border-orange-400 text-orange-400 px-2 py-1 rounded text-[10px] transition-colors">
<Layers className="w-3 h-3" /> Save counter
</button>
)}
<button onClick={() => handleSave(idx, 'new', undefined, mr.suggested_regime_tag)}
className="flex items-center gap-1 border border-slate-600 hover:border-slate-400 text-slate-400 px-2 py-1 rounded text-[10px] transition-colors">
<Save className="w-3 h-3" /> Save new
</button>
</div>
</div>
)
}
return (
<div className="flex gap-1.5">
<button onClick={() => handleFindMatching(idx)} disabled={loading}
className="flex items-center gap-1.5 border border-violet-700 hover:border-violet-400 text-violet-400 px-2.5 py-1.5 rounded text-xs transition-colors disabled:opacity-50">
{loading ? <RefreshCw className="w-3 h-3 animate-spin" /> : <GitMerge className="w-3 h-3" />}
Find matching
</button>
<button onClick={() => handleSave(idx)}
className="flex items-center gap-1.5 border border-slate-600 hover:border-violet-500 hover:text-violet-300 text-slate-400 px-2.5 py-1.5 rounded text-xs transition-colors">
<Save className="w-3 h-3" /> Save
</button>
</div>
)
})()}
</div>
</div>
</div>