diff --git a/scripts/backtest-risk.ts b/scripts/backtest-risk.ts index 8775f6e..3c684c3 100644 --- a/scripts/backtest-risk.ts +++ b/scripts/backtest-risk.ts @@ -830,6 +830,9 @@ async function predict(report: WeeklyReport, prev: WeeklyReport | null, priorYea observationAgeMinutes: latestObservation ? Math.max(0, Math.round((decisionAt.getTime() - latestObservation.observedAt.getTime()) / 60_000)) : null, + // 백테스트에는 관측소 거리 기록이 없다. null 이면 거리 벌점이 붙지 않아 + // **과거 신뢰도를 실제보다 후하게** 본다 — 지표를 비교할 때 이 차이를 기억한다. + observationDistanceKm: null, }; // ===== 프로덕션 도메인 코드 (재구현 없음) ===== @@ -839,7 +842,11 @@ async function predict(report: WeeklyReport, prev: WeeklyReport | null, priorYea const variables = evaluateRiskVariables(bundle, ruleScore); const reportWeights = evaluateReportWeights(bundle.verifiedReports, ruleScore); const minLevelTriggers = deriveMinLevelTriggers(bundle.verifiedReports); - const confidence = deriveConfidence(variables.missing.length, bundle.observationAgeMinutes); + const confidence = deriveConfidence( + variables.missing, + bundle.observationAgeMinutes, + bundle.observationDistanceKm, + ); const result = RiskEngine.calculate({ variables: applyHorizon(variables.factors, 'now'), reportWeights: applyHorizon(reportWeights, 'now'), diff --git a/src/contexts/risk/adapter/in/web/dto/beach-risk.response.ts b/src/contexts/risk/adapter/in/web/dto/beach-risk.response.ts index e825c71..76059ef 100644 --- a/src/contexts/risk/adapter/in/web/dto/beach-risk.response.ts +++ b/src/contexts/risk/adapter/in/web/dto/beach-risk.response.ts @@ -169,11 +169,30 @@ export class PublicRiskPointResponse { @ApiProperty({ example: 'medium', - description: '데이터 신뢰도. 먼 시점일수록 예측 불확실성으로 한 단계씩 낮아진다.', + description: [ + '**관측 자료 상태**(예측 정확도가 아니다).', + '', + '⚠️ 이름이 confidence 라서 "이 예측을 믿어도 된다" 로 읽히기 쉬운데 **그런 뜻이 아니다.**', + '이 값이 답하는 질문은 하나다 — 이 판정을 뒷받침할 **관측 자료가 충분한가.** 입력이', + '완벽해도 룰이 틀렸으면 예측은 틀리고, 이 값은 그것을 보지 않는다.', + '', + '**무엇을 보나** — 무엇이 비었나(수온 결측이 가장 무겁다) · 얼마나 오래됐나(3시간) ·', + '관측소가 얼마나 먼가(10km 초과 한 단계, 30km 초과 두 단계).', + '먼 시점일수록 예측 불확실성으로 한 단계씩 더 낮아진다.', + '', + '시민 화면에는 `dataConfidenceLabel` 을 쓴다 — 자료 이야기만 하는 문구다.', + ].join(' '), enum: ['high', 'medium', 'low'], }) dataConfidence!: string; + @ApiProperty({ + example: '관측 자료 일부 없음', + description: + '시민에게 보여줄 관측 자료 상태 문구(요청 언어로). "신뢰도" 라는 말을 쓰지 않는 이유는 dataConfidence 설명 참고.', + }) + dataConfidenceLabel!: string; + @ApiProperty({ example: '2026-07-10T09:00:00.000Z', description: '산출 생성 일시' }) generatedAt!: string; @@ -231,11 +250,30 @@ export class PublicBeachRiskResponse { @ApiProperty({ example: 'high', - description: '데이터 신뢰도 — 현재 시점', + description: [ + '**관측 자료 상태**(예측 정확도가 아니다).', + '', + '⚠️ 이름이 confidence 라서 "이 예측을 믿어도 된다" 로 읽히기 쉬운데 **그런 뜻이 아니다.**', + '이 값이 답하는 질문은 하나다 — 이 판정을 뒷받침할 **관측 자료가 충분한가.** 입력이', + '완벽해도 룰이 틀렸으면 예측은 틀리고, 이 값은 그것을 보지 않는다.', + '', + '**무엇을 보나** — 무엇이 비었나(수온 결측이 가장 무겁다) · 얼마나 오래됐나(3시간) ·', + '관측소가 얼마나 먼가(10km 초과 한 단계, 30km 초과 두 단계).', + '먼 시점일수록 예측 불확실성으로 한 단계씩 더 낮아진다.', + '', + '시민 화면에는 `dataConfidenceLabel` 을 쓴다 — 자료 이야기만 하는 문구다.', + ].join(' '), enum: ['high', 'medium', 'low'], }) dataConfidence!: string; + @ApiProperty({ + example: '관측 자료 일부 없음', + description: + '시민에게 보여줄 관측 자료 상태 문구(요청 언어로). "신뢰도" 라는 말을 쓰지 않는 이유는 dataConfidence 설명 참고.', + }) + dataConfidenceLabel!: string; + @ApiProperty({ example: '2026-07-10T09:00:00.000Z', description: '산출 생성 일시 — 현재 시점', diff --git a/src/contexts/risk/adapter/out/persistence/risk-input.kysely-query.ts b/src/contexts/risk/adapter/out/persistence/risk-input.kysely-query.ts index ae09239..69230ef 100644 --- a/src/contexts/risk/adapter/out/persistence/risk-input.kysely-query.ts +++ b/src/contexts/risk/adapter/out/persistence/risk-input.kysely-query.ts @@ -153,6 +153,11 @@ export class RiskInputKyselyQuery implements RiskInputPort { ? Math.max(0, Math.round((now - latestObservation.observedAt.getTime()) / 60000)) : null; + // 거리는 **해양** 관측소 것만 쓴다. 수온·파고·해류가 거기서 오고, 기상 관측소는 제주 + // 전체에 둘뿐이라 거리로 벌점을 주면 모든 해변이 함께 내려갈 뿐이다(고칠 방법이 없다). + // 대표가 낡아 다른 해양관측소가 선택됐다면 **그 관측소의 거리**여야 한다. + const observationDistanceKm = marineRow?.distanceKm ?? null; + // 7일 평균 수온 const avgRow = await this.db .selectFrom('observations as o') @@ -239,6 +244,7 @@ export class RiskInputKyselyQuery implements RiskInputPort { pastOccurrenceCount, verifiedReports, observationAgeMinutes, + observationDistanceKm, forecasts, }; } @@ -416,6 +422,7 @@ export class RiskInputKyselyQuery implements RiskInputPort { .where('m.beach_id', '=', beachId) .where('m.station_type', '=', stationType) .select([ + 'm.distance_km as distanceKm', 'o.observed_at as observedAt', 'o.water_temp as waterTemp', 'o.wave_height as waveHeight', @@ -438,6 +445,7 @@ export class RiskInputKyselyQuery implements RiskInputPort { return row ? { + distanceKm: numOrNull(row.distanceKm), observedAt: new Date(row.observedAt), waterTemp: numOrNull(row.waterTemp), waveHeight: numOrNull(row.waveHeight), @@ -464,7 +472,13 @@ const STALE_OBSERVATION_HOURS = 24; type StationType = 'marine' | 'weather'; /** 관측 한 행 (유형별 최신본). */ -type ObservationRow = ObservationInput; +/** + * 관측 한 행 + **그 값을 준 관측소까지의 거리.** + * + * 거리는 도메인 입력(ObservationInput)에 넣지 않는다 — 위험 요인 평가는 거리를 쓰지 않고, + * 신뢰도 판정만 쓴다. 평가 입력에 섞으면 룰이 거리를 보는 것처럼 읽힌다. + */ +type ObservationRow = ObservationInput & { distanceKm: number | null }; /** a ?? b — 유형별 담당 컬럼을 우선하되, 비면 다른 유형 행으로 보완한다. */ function pick(primary: number | null, fallback: number | null): number | null { diff --git a/src/contexts/risk/application/port/in/risk-use-cases.ts b/src/contexts/risk/application/port/in/risk-use-cases.ts index 1581d35..31bd33b 100644 --- a/src/contexts/risk/application/port/in/risk-use-cases.ts +++ b/src/contexts/risk/application/port/in/risk-use-cases.ts @@ -88,6 +88,14 @@ export interface PublicRiskPointView { riskScore: number; factors: PublicRiskFactorView[]; // 요약 원인 3~5개 dataConfidence: DataConfidence; + /** + * 시민에게 보여줄 **관측 자료 상태** 문구. + * + * ⚠️ `dataConfidence` 를 "신뢰도 높음" 으로 옮기면 *"이 예측을 믿어도 된다"* 로 읽힌다. + * 그건 측정한 적 없는 주장이다(해변별 정확도 표본 0건). 이 값이 답하는 질문은 + * **"이 판정을 뒷받침할 관측 자료가 충분한가"** 뿐이라, 라벨도 자료 이야기만 한다. + */ + dataConfidenceLabel: string; generatedAt: Date; /** * 이 지평의 단계가 운영자 수동 상향으로 올라간 것인가. @@ -121,6 +129,14 @@ export interface PublicBeachRiskView { factors: PublicRiskFactorView[]; // 요약 원인 3~5개 guideText: string; dataConfidence: DataConfidence; + /** + * 시민에게 보여줄 **관측 자료 상태** 문구. + * + * ⚠️ `dataConfidence` 를 "신뢰도 높음" 으로 옮기면 *"이 예측을 믿어도 된다"* 로 읽힌다. + * 그건 측정한 적 없는 주장이다(해변별 정확도 표본 0건). 이 값이 답하는 질문은 + * **"이 판정을 뒷받침할 관측 자료가 충분한가"** 뿐이라, 라벨도 자료 이야기만 한다. + */ + dataConfidenceLabel: string; generatedAt: Date | null; /** * 이 단계가 **운영자가 손으로 올린 것**인가. diff --git a/src/contexts/risk/application/service/calculate-risk.service.ts b/src/contexts/risk/application/service/calculate-risk.service.ts index 5622825..a06e337 100644 --- a/src/contexts/risk/application/service/calculate-risk.service.ts +++ b/src/contexts/risk/application/service/calculate-risk.service.ts @@ -220,8 +220,10 @@ export class CalculateRiskService implements CalculateRiskUseCase { ...deriveNearbyMinTriggers(bundle.nearbyAlert), ]; const baseConfidence = deriveConfidence( - variables.missing.length, + // 개수가 아니라 **코드**를 넘긴다 — 수온 결측은 다른 것보다 무겁다. + variables.missing, bundle.observationAgeMinutes, + bundle.observationDistanceKm, ); // 지평은 순차로 둔다. 같은 해변의 세 지평이 동시에 돌 이유가 없고(양이 적다), diff --git a/src/contexts/risk/application/service/get-beach-risk-detail.service.ts b/src/contexts/risk/application/service/get-beach-risk-detail.service.ts index c128461..51aaf4d 100644 --- a/src/contexts/risk/application/service/get-beach-risk-detail.service.ts +++ b/src/contexts/risk/application/service/get-beach-risk-detail.service.ts @@ -1,4 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; +import { dataConfidenceLabelOf } from '../../domain/data-confidence-label'; import { Id } from '@shared/kernel/id'; import { NotFoundError } from '@shared/kernel/domain-error'; import { RiskHorizon, riskLevelLabelOf } from '@shared/kernel/risk-level'; @@ -100,6 +101,7 @@ export class GetBeachRiskDetailService implements GetBeachRiskDetailUseCase { factors: [], guideText: buildSafetyGuide('safe', locale), dataConfidence: 'low', + dataConfidenceLabel: dataConfidenceLabelOf('low', locale), generatedAt: null, manuallyRaised: false, riskTimeline: [], @@ -116,6 +118,7 @@ export class GetBeachRiskDetailService implements GetBeachRiskDetailUseCase { factors: primary.factors, guideText: buildSafetyGuide(primary.riskLevel, locale), dataConfidence: primary.dataConfidence, + dataConfidenceLabel: dataConfidenceLabelOf(primary.dataConfidence, locale), generatedAt: primary.generatedAt, // 최소 단계 보장이 걸렸고 그 근거가 **사람**일 때만 true. 제보·인근 출현 기반 보장 // (RISK-002)은 여전히 시스템 판단이므로 여기서 구분한다. @@ -160,6 +163,7 @@ export class GetBeachRiskDetailService implements GetBeachRiskDetailUseCase { scoreDelta: f.delta, })), dataConfidence: card.confidence, + dataConfidenceLabel: dataConfidenceLabelOf(card.confidence, locale), generatedAt: card.generatedAt, manuallyRaised: card.minLevelRuleCode === MANUAL_OVERRIDE_RULE_CODE, }); diff --git a/src/contexts/risk/domain/data-confidence-label.ts b/src/contexts/risk/domain/data-confidence-label.ts new file mode 100644 index 0000000..84c1e3c --- /dev/null +++ b/src/contexts/risk/domain/data-confidence-label.ts @@ -0,0 +1,49 @@ +import { DataConfidence } from '@shared/kernel/risk-level'; +import { Locale, LocaleText, text } from '@shared/i18n/locale'; + +/** + * 관측 자료 상태 라벨 (시민 화면용). + * + * ── ⚠️ 왜 '신뢰도' 라고 부르지 않나 ───────────────────────────────────────────────── + * 값 자체는 `high | medium | low` 이고 API 필드 이름도 `dataConfidence` 다. 그런데 그것을 + * 시민에게 **"신뢰도 높음"** 으로 보여주면 *"이 예측을 믿어도 된다"* 로 읽힌다. + * + * 그건 우리가 **측정한 적 없는 주장**이다. 해변별 예측 정확도 표본은 현재 0건이고 + * (docs/backtest.md, #83), 전체 정확도조차 시군구 단위 정답으로 거칠게 잰 값이다. + * + * deriveConfidence 가 실제로 답하는 질문은 하나다 — **"이 판정을 뒷받침할 관측 자료가 + * 충분한가."** 입력이 완벽해도 룰이 틀렸으면 예측은 틀린다. 이 값은 그것을 보지 않는다. + * + * 이 서비스에서는 그 차이가 위험한 방향으로 어긋난다. `안전` + `신뢰도 높음` 을 본 사람은 + * 물에 들어간다. 그런데 그 "높음" 이 실제로 보장하는 것은 **10km 안의 부이에서 3시간 안에 + * 값이 다 들어왔다** 는 것뿐이다. + * + * 그래서 라벨은 **자료 이야기만** 한다. 판단은 위험 단계가 한다. + */ +const LABELS: Record = { + high: { + ko: '관측 자료 충분', + en: 'Full observation data', + zh: '观测数据充足', + ja: '観測データ十分', + }, + medium: { + ko: '관측 자료 일부 없음', + en: 'Some observation data missing', + zh: '部分观测数据缺失', + ja: '観測データ一部なし', + }, + low: { + // '부족' 이라고만 하면 "조금 모자라다" 로 읽힌다. low 는 수온을 한 값도 못 받았거나 + // 관측이 하루 넘게 끊긴 상태라, 판정의 근거가 거의 없다는 뜻이다. + ko: '관측 자료 많이 부족', + en: 'Observation data largely unavailable', + zh: '观测数据严重不足', + ja: '観測データ大幅に不足', + }, +}; + +/** 시민에게 보여줄 관측 자료 상태 문구. */ +export function dataConfidenceLabelOf(confidence: DataConfidence, locale: Locale): string { + return text(LABELS[confidence], locale); +} diff --git a/src/contexts/risk/domain/data-confidence.spec.ts b/src/contexts/risk/domain/data-confidence.spec.ts new file mode 100644 index 0000000..268afa0 --- /dev/null +++ b/src/contexts/risk/domain/data-confidence.spec.ts @@ -0,0 +1,112 @@ +import { THRESHOLDS, deriveConfidence } from './risk-assessment'; +import { dataConfidenceLabelOf } from './data-confidence-label'; + +/** + * 관측 자료 상태 판정. + * + * ── 이 판정이 답하는 것과 답하지 않는 것 ───────────────────────────────────────────── + * 답하는 것: **이 판정을 뒷받침할 관측 자료가 충분한가.** + * 답하지 않는 것: **예측이 맞는가.** 입력이 완벽해도 룰이 틀렸으면 예측은 틀린다. + * + * 그 차이를 흐리면 이 서비스에서 위험한 방향으로 어긋난다 — `안전` + `높음` 을 본 사람은 + * 물에 들어간다. + */ +describe('deriveConfidence', () => { + const FRESH = 10; + const NEAR = 3; + + it('다 있고 신선하고 가까우면 high', () => { + expect(deriveConfidence([], FRESH, NEAR)).toBe('high'); + }); + + it('관측이 아예 없으면 low', () => { + expect(deriveConfidence([], null, null)).toBe('low'); + }); + + describe('⚠️ 결측은 개수가 아니라 무게로 센다', () => { + /** + * 이 블록이 **실제 결함**을 고정한다. + * + * 예전에는 개수만 셌고 low 기준이 3이었다. 그런데 수온이 없으면 TEMP_UP 과 + * TEMP_7D_AVG 가 **함께** 빠져 개수가 2였다 — 그래서 수온을 한 값도 못 받은 해변이 + * 'medium' 으로 나왔다. 입력의 절반이 없는데 "보통" 이라고 답한 셈이다. + */ + it('수온을 한 값도 못 받으면 low 다 — 예전에는 medium 이었다', () => { + expect(deriveConfidence(['TEMP_UP', 'TEMP_7D_AVG'], FRESH, NEAR)).toBe('low'); + }); + + it('유향·유속만 없으면 medium — 제주 해변 대부분이 이 상태다', () => { + expect(deriveConfidence(['CURRENT_INFLOW'], FRESH, NEAR)).toBe('medium'); + }); + + it('부차 요인 둘이 없어도 medium', () => { + expect(deriveConfidence(['CURRENT_INFLOW', 'WAVE_HIGH'], FRESH, NEAR)).toBe('medium'); + }); + + it('부차 요인 셋이 겹치면 low', () => { + expect(deriveConfidence(['CURRENT_INFLOW', 'WAVE_HIGH', 'WIND_INFLOW'], FRESH, NEAR)).toBe( + 'low', + ); + }); + + it('표에 없는 코드도 1점으로 센다 — 룰이 늘어도 조용히 0점이 되지 않는다', () => { + expect(deriveConfidence(['NEW_RULE_A', 'NEW_RULE_B', 'NEW_RULE_C'], FRESH, NEAR)).toBe('low'); + }); + }); + + describe('⚠️ 관측소가 멀면 내린다 — 예전에는 거리를 아예 보지 않았다', () => { + it('대표 거리(10km) 안이면 그대로 high', () => { + expect(deriveConfidence([], FRESH, THRESHOLDS.representativeDistanceKm)).toBe('high'); + }); + + it('10km 를 넘으면 한 단계 내린다 — 그 값은 이 해변의 것이라고 보기 어렵다', () => { + expect(deriveConfidence([], FRESH, THRESHOLDS.representativeDistanceKm + 0.1)).toBe('medium'); + }); + + it('30km 를 넘으면 두 단계 — 엔진이 "인근" 으로도 치지 않는 거리다', () => { + expect(deriveConfidence([], FRESH, THRESHOLDS.farDistanceKm + 1)).toBe('low'); + }); + + it('거리를 모르면 벌점이 없다 — 그 상황은 결측 무게가 이미 잡는다', () => { + expect(deriveConfidence([], FRESH, null)).toBe('high'); + }); + + it('결측과 거리가 겹치면 함께 내려간다', () => { + // 유속 결측(medium) + 먼 관측소(한 단계) = low + expect(deriveConfidence(['CURRENT_INFLOW'], FRESH, 25)).toBe('low'); + }); + }); + + describe('신선도', () => { + it('3시간을 넘으면 high 를 주지 않는다', () => { + expect(deriveConfidence([], THRESHOLDS.freshObservationMinutes + 1, NEAR)).toBe('medium'); + }); + + it('하루를 넘으면 low', () => { + expect(deriveConfidence([], THRESHOLDS.staleObservationMinutes + 1, NEAR)).toBe('low'); + }); + }); +}); + +describe('dataConfidenceLabelOf', () => { + it('⚠️ 라벨에 "신뢰도" 라는 말을 쓰지 않는다', () => { + // "신뢰도 높음" 은 "이 예측을 믿어도 된다" 로 읽힌다. 그건 측정한 적 없는 주장이다 + // (해변별 정확도 표본 0건). 라벨은 자료 이야기만 해야 한다. + for (const level of ['high', 'medium', 'low'] as const) { + expect(dataConfidenceLabelOf(level, 'ko')).not.toContain('신뢰'); + } + }); + + it('자료 이야기를 한다', () => { + expect(dataConfidenceLabelOf('high', 'ko')).toContain('관측 자료'); + expect(dataConfidenceLabelOf('low', 'ko')).toContain('부족'); + }); + + it('지원 언어 넷을 모두 준다', () => { + for (const locale of ['ko', 'en', 'zh', 'ja'] as const) { + expect(dataConfidenceLabelOf('medium', locale).length).toBeGreaterThan(0); + } + // 언어별로 실제 다른 문구여야 한다 — ko 로 폴백되면 외국인에게 한국어가 나간다. + expect(dataConfidenceLabelOf('medium', 'en')).not.toBe(dataConfidenceLabelOf('medium', 'ko')); + }); +}); diff --git a/src/contexts/risk/domain/risk-assessment.spec.ts b/src/contexts/risk/domain/risk-assessment.spec.ts index 180c494..9e14e4a 100644 --- a/src/contexts/risk/domain/risk-assessment.spec.ts +++ b/src/contexts/risk/domain/risk-assessment.spec.ts @@ -43,6 +43,7 @@ function bundle(nearbyAlert: NearbyAlertInput | null): RiskInputBundle { pastOccurrenceCount: 0, verifiedReports: [], observationAgeMinutes: 10, + observationDistanceKm: 3, forecasts: [], }; } diff --git a/src/contexts/risk/domain/risk-assessment.ts b/src/contexts/risk/domain/risk-assessment.ts index 5a57426..7982ebb 100644 --- a/src/contexts/risk/domain/risk-assessment.ts +++ b/src/contexts/risk/domain/risk-assessment.ts @@ -98,6 +98,17 @@ export interface RiskInputBundle { pastOccurrenceCount: number; verifiedReports: VerifiedReportInput[]; observationAgeMinutes: number | null; // 최신 관측 경과(분), 없으면 null + /** + * 값을 실제로 준 **해양 관측소**까지의 거리(km). 해양 관측이 없으면 null. + * + * 대표 관측소의 거리가 아니라 **이번에 선택된 관측소**의 거리다. 대표가 낡으면 더 먼 + * 관측소가 선택되는데(findLatestObservation), 그때 값은 더 멀리서 온 것이다. + * + * 기상 관측소 거리는 쓰지 않는다. 제주 전체에 둘뿐이라(대표 평균 22.6km) 벌점을 주면 + * 모든 해변이 함께 내려갈 뿐이고, **운영자가 고칠 방법이 없다.** 신뢰도는 고칠 수 있는 + * 것을 가리켜야 한다. + */ + observationDistanceKm: number | null; /** * 이 해변의 향후 기상 예보(weather_forecasts). 24h/72h 지평의 WAVE_HIGH/WIND_INFLOW 를 * **현재값 × 계수가 아니라 예보값으로** 재평가하는 데 쓴다. 비어 있으면 계수 폴백. @@ -117,6 +128,24 @@ export const THRESHOLDS = { toxicHighConfidence: 0.8, // MIN_TOXIC_HIGH 기준 freshObservationMinutes: 180, // 신뢰도 high 기준 staleObservationMinutes: 24 * 60, // 신뢰도 medium 상한 + /** + * 관측값이 그 해변을 대표한다고 볼 수 있는 거리(km). 넘으면 신뢰도를 한 단계 내린다. + * + * 10km 는 이 저장소가 이미 같은 질문에 내놓은 답이다 — 정답 데이터에서 출현 기록을 + * 해변에 붙일 때 쓰는 반경이 10km 이고, 그 근거는 "이보다 넓으면 해변별 차이가 사라진다" + * 였다(GroundtruthConfig). 관측값도 같은 질문을 받는다: **이 값이 이 해변의 것인가.** + * + * ⚠️ 지금 제주 해변 12곳의 대표 해양관측소는 1.1~10.1km 다. 곽지과물(10.1km)만 이 선을 + * 넘는다 — 0.1km 차이로 내려간다. 선을 15km 로 옮기면 곽지가 안 걸리지만, 그건 숫자를 + * 결과에 맞춘 것이지 근거가 아니다. 곽지의 최근접 해양관측소가 실제로 경계에 있다는 + * 사실이 드러나는 편이 낫다. + */ + representativeDistanceKm: 10, + /** + * 이 거리를 넘으면 두 단계 내린다. 30km 는 엔진이 "인근 출현" 으로 치는 반경이다 — + * 그보다 멀면 위험도 계산에서조차 같은 해역으로 보지 않는 거리다. + */ + farDistanceKm: 30, } as const; function mkVariable(code: RiskFactorCode, ruleScore: RuleScoreLookup, detail: string | null): FactorContribution { @@ -394,11 +423,94 @@ export function deriveNearbyMinTriggers(nearby: NearbyAlertInput | null): MinLev } /** - * 데이터 신뢰도 판정 (RISK-005). 결측 요인 수와 관측 최신성으로 등급을 낮춘다. + * 결측 요인별 무게. + * + * ── 왜 개수로 세면 안 되나 ─────────────────────────────────────────────────────────── + * 예전에는 결측 **개수**만 셌다. 그러면 수온을 못 보는 상태와 부차 요인 셋을 못 보는 상태가 + * 같은 등급이 된다. 해파리 위험도에서 수온은 핵심 동인인데도 그렇다. + * + * ⚠️ 더 나빴던 것 — 수온이 없으면 TEMP_UP 과 TEMP_7D_AVG 가 함께 빠져 개수가 **2** 였다. + * low 기준이 3이었으므로 **수온을 한 값도 못 받은 해변이 'medium' 으로 나왔다.** + * 입력의 절반이 없는데 "보통" 이라고 답한 셈이다. + * + * 그래서 수온 두 요인의 무게 합을 3으로 맞춘다 — 수온이 없으면 그 자체로 low 다. + * 나머지는 1이고, 셋이 겹쳐야 low 가 된다. + */ +const MISSING_FACTOR_WEIGHTS: Record = { + TEMP_UP: 2, + TEMP_7D_AVG: 1, + WAVE_HIGH: 1, + WIND_INFLOW: 1, + CURRENT_INFLOW: 1, +}; + +/** 표에 없는 코드의 기본 무게. 룰이 늘어도 조용히 0점이 되지 않게 한다. */ +const DEFAULT_MISSING_WEIGHT = 1; + +/** low 로 떨어지는 결측 무게 합. */ +const LOW_CONFIDENCE_WEIGHT = 3; + +function missingWeight(codes: string[]): number { + return codes.reduce( + (sum, code) => sum + (MISSING_FACTOR_WEIGHTS[code] ?? DEFAULT_MISSING_WEIGHT), + 0, + ); +} + +/** + * 관측소 거리로 내릴 단계 수. + * + * 값이 다 있고 신선해도 **40km 떨어진 관측소의 수온**이면 그건 이 해변의 값이 아니다. + * 예전 판정은 거리를 아예 보지 않아서, 그 경우에도 'high' 가 나왔다. + * + * 거리를 모르면(해양 관측이 없으면) 0 단계다 — 그 상황은 결측 무게가 이미 크게 잡는다. + */ +function distanceSteps(distanceKm: number | null): number { + if (distanceKm === null) return 0; + if (distanceKm > THRESHOLDS.farDistanceKm) return 2; + if (distanceKm > THRESHOLDS.representativeDistanceKm) return 1; + return 0; +} + +const CONFIDENCE_LADDER: DataConfidence[] = ['high', 'medium', 'low']; + +function lower(level: DataConfidence, steps: number): DataConfidence { + const idx = Math.min(CONFIDENCE_LADDER.indexOf(level) + steps, CONFIDENCE_LADDER.length - 1); + return CONFIDENCE_LADDER[idx]; +} + +/** + * 데이터 신뢰도 판정 (RISK-005). + * + * ── ⚠️ 이 값이 답하지 않는 것 ──────────────────────────────────────────────────────── + * **예측이 맞는지는 전혀 보지 않는다.** 입력이 완벽해도 룰이 틀렸으면 예측은 틀린다. + * 이 값이 답하는 질문은 하나다 — **"이 판정을 뒷받침할 관측 자료가 충분한가."** + * + * 그래서 시민 화면에서는 '신뢰도' 가 아니라 **'관측 자료 상태'** 로 부른다. "신뢰도 높음" + * 은 "이 예측을 믿어도 된다" 로 읽히는데, 그건 우리가 아직 측정한 적 없는 주장이다 + * (해변별 정확도 표본은 현재 0건 — docs/backtest.md). + * + * ── 보는 것 셋 ─────────────────────────────────────────────────────────────────────── + * 1. 무엇이 비었나 — 개수가 아니라 무게(수온이 무겁다) + * 2. 얼마나 오래됐나 — 3시간 넘으면 high 를 못 준다 + * 3. 얼마나 먼가 — 10km 넘으면 한 단계, 30km 넘으면 두 단계 */ -export function deriveConfidence(missingCount: number, observationAgeMinutes: number | null): DataConfidence { - if (observationAgeMinutes === null || missingCount >= 3) return 'low'; - if (missingCount === 0 && observationAgeMinutes <= THRESHOLDS.freshObservationMinutes) return 'high'; - if (observationAgeMinutes <= THRESHOLDS.staleObservationMinutes) return 'medium'; - return 'low'; +export function deriveConfidence( + missingCodes: string[], + observationAgeMinutes: number | null, + observationDistanceKm: number | null = null, +): DataConfidence { + // 관측이 아예 없으면 거리도 무게도 따질 것이 없다. + if (observationAgeMinutes === null) return 'low'; + + const weight = missingWeight(missingCodes); + if (weight >= LOW_CONFIDENCE_WEIGHT) return 'low'; + if (observationAgeMinutes > THRESHOLDS.staleObservationMinutes) return 'low'; + + const base: DataConfidence = + weight === 0 && observationAgeMinutes <= THRESHOLDS.freshObservationMinutes + ? 'high' + : 'medium'; + + return lower(base, distanceSteps(observationDistanceKm)); }