feat: add local surface review triage
This commit is contained in:
@@ -181,6 +181,70 @@ export interface LidarLocalSurfaceTimeline {
|
||||
authority: LidarLocalSurfaceModel["authority"];
|
||||
}
|
||||
|
||||
export type LidarLocalSurfaceReviewReason =
|
||||
| "prediction-tail"
|
||||
| "prediction-inlier-drop"
|
||||
| "surface-height-jump"
|
||||
| "surface-slope-jump"
|
||||
| "surface-roughness-jump";
|
||||
|
||||
export interface LidarLocalSurfaceReviewItem {
|
||||
rank: number;
|
||||
frameIndex: number;
|
||||
sourceFrameIndex: number;
|
||||
sessionSeconds: number;
|
||||
episodeId: string;
|
||||
attention: "high" | "review";
|
||||
attentionScore: number;
|
||||
reasons: LidarLocalSurfaceReviewReason[];
|
||||
prediction: {
|
||||
available: boolean;
|
||||
residualP50M: number;
|
||||
residualP95M: number;
|
||||
inlierFraction: number;
|
||||
};
|
||||
temporal: {
|
||||
compared: boolean;
|
||||
heightDeltaM: number;
|
||||
slopeDeltaDeg: number;
|
||||
roughnessDeltaM: number;
|
||||
};
|
||||
surface: {
|
||||
sensorHeightM: number;
|
||||
slopeDeg: number;
|
||||
roughnessM: number;
|
||||
confidence: number;
|
||||
};
|
||||
stepCandidatePointCount: number;
|
||||
}
|
||||
|
||||
export interface LidarLocalSurfaceReview {
|
||||
reviewProfileId: "missioncore-local-surface-attention/v1";
|
||||
modelId: string;
|
||||
sourcePackId: string;
|
||||
sessionId: string;
|
||||
available: boolean;
|
||||
criteria: {
|
||||
predictionTailResidualP95M: number;
|
||||
predictionInlierFractionFloor: number;
|
||||
surfaceHeightJumpM: number;
|
||||
surfaceSlopeJumpDeg: number;
|
||||
surfaceRoughnessJumpM: number;
|
||||
highAttentionScore: number;
|
||||
episodeMaxFrameGap: number;
|
||||
};
|
||||
summary: {
|
||||
itemCount: number;
|
||||
episodeCount: number;
|
||||
highAttentionCount: number;
|
||||
reviewAttentionCount: number;
|
||||
reasonCounts: Record<LidarLocalSurfaceReviewReason, number>;
|
||||
};
|
||||
items: LidarLocalSurfaceReviewItem[];
|
||||
groundTruth: false;
|
||||
authority: LidarLocalSurfaceModel["authority"];
|
||||
}
|
||||
|
||||
export class LidarLocalSurfaceContractError extends Error {}
|
||||
|
||||
export class LidarLocalSurfaceApiError extends Error {
|
||||
@@ -200,6 +264,15 @@ const SAFE_MODEL_ID = new RegExp(`^${LOCAL_SURFACE_MODEL_PREFIX}[a-f0-9]{64}$`);
|
||||
const SAFE_PACK_ID = /^e10-lidar-pack-[a-f0-9]{64}$/;
|
||||
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/;
|
||||
const SAFE_KEY = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
const SAFE_EPISODE_ID = /^episode-[0-9]{2,4}$/;
|
||||
const REVIEW_PROFILE_ID = "missioncore-local-surface-attention/v1";
|
||||
const REVIEW_REASONS = [
|
||||
"prediction-tail",
|
||||
"prediction-inlier-drop",
|
||||
"surface-height-jump",
|
||||
"surface-slope-jump",
|
||||
"surface-roughness-jump",
|
||||
] as const satisfies readonly LidarLocalSurfaceReviewReason[];
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
@@ -925,6 +998,356 @@ export function parseLidarLocalSurfaceTimeline(
|
||||
};
|
||||
}
|
||||
|
||||
export function parseLidarLocalSurfaceReview(
|
||||
value: unknown,
|
||||
): LidarLocalSurfaceReview {
|
||||
const source = record(value, "LiDAR local-surface review");
|
||||
if (
|
||||
source.schema_version !== `${LOCAL_SURFACE_SCHEMA_PREFIX}-review/v1`
|
||||
|| source.review_profile_id !== REVIEW_PROFILE_ID
|
||||
|| source.access !== "read-only"
|
||||
|| source.ground_truth !== false
|
||||
) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"LiDAR local-surface review несовместим",
|
||||
);
|
||||
}
|
||||
const available = boolean(source.available, "available");
|
||||
const criteriaSource = record(source.criteria, "criteria");
|
||||
const criteria = {
|
||||
predictionTailResidualP95M: finite(
|
||||
criteriaSource.prediction_tail_residual_p95_m,
|
||||
"criteria.prediction_tail_residual_p95_m",
|
||||
),
|
||||
predictionInlierFractionFloor: finite(
|
||||
criteriaSource.prediction_inlier_fraction_floor,
|
||||
"criteria.prediction_inlier_fraction_floor",
|
||||
),
|
||||
surfaceHeightJumpM: finite(
|
||||
criteriaSource.surface_height_jump_m,
|
||||
"criteria.surface_height_jump_m",
|
||||
),
|
||||
surfaceSlopeJumpDeg: finite(
|
||||
criteriaSource.surface_slope_jump_deg,
|
||||
"criteria.surface_slope_jump_deg",
|
||||
),
|
||||
surfaceRoughnessJumpM: finite(
|
||||
criteriaSource.surface_roughness_jump_m,
|
||||
"criteria.surface_roughness_jump_m",
|
||||
),
|
||||
highAttentionScore: finite(
|
||||
criteriaSource.high_attention_score,
|
||||
"criteria.high_attention_score",
|
||||
),
|
||||
episodeMaxFrameGap: integer(
|
||||
criteriaSource.episode_max_frame_gap,
|
||||
"criteria.episode_max_frame_gap",
|
||||
),
|
||||
};
|
||||
if (
|
||||
criteria.predictionTailResidualP95M <= 0
|
||||
|| criteria.predictionInlierFractionFloor <= 0
|
||||
|| criteria.predictionInlierFractionFloor >= 1
|
||||
|| criteria.surfaceHeightJumpM <= 0
|
||||
|| criteria.surfaceSlopeJumpDeg <= 0
|
||||
|| criteria.surfaceRoughnessJumpM <= 0
|
||||
|| criteria.highAttentionScore <= 1
|
||||
|| criteria.episodeMaxFrameGap < 1
|
||||
|| criteria.episodeMaxFrameGap > 100
|
||||
) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"LiDAR local-surface review criteria несовместимы",
|
||||
);
|
||||
}
|
||||
const summarySource = record(source.summary, "summary");
|
||||
const reasonCountsSource = record(
|
||||
summarySource.reason_counts,
|
||||
"summary.reason_counts",
|
||||
);
|
||||
const reasonCounts: Record<LidarLocalSurfaceReviewReason, number> = {
|
||||
"prediction-tail": integer(
|
||||
reasonCountsSource["prediction-tail"],
|
||||
"reason_counts.prediction-tail",
|
||||
),
|
||||
"prediction-inlier-drop": integer(
|
||||
reasonCountsSource["prediction-inlier-drop"],
|
||||
"reason_counts.prediction-inlier-drop",
|
||||
),
|
||||
"surface-height-jump": integer(
|
||||
reasonCountsSource["surface-height-jump"],
|
||||
"reason_counts.surface-height-jump",
|
||||
),
|
||||
"surface-slope-jump": integer(
|
||||
reasonCountsSource["surface-slope-jump"],
|
||||
"reason_counts.surface-slope-jump",
|
||||
),
|
||||
"surface-roughness-jump": integer(
|
||||
reasonCountsSource["surface-roughness-jump"],
|
||||
"reason_counts.surface-roughness-jump",
|
||||
),
|
||||
};
|
||||
const summary = {
|
||||
itemCount: integer(summarySource.item_count, "summary.item_count"),
|
||||
episodeCount: integer(summarySource.episode_count, "summary.episode_count"),
|
||||
highAttentionCount: integer(
|
||||
summarySource.high_attention_count,
|
||||
"summary.high_attention_count",
|
||||
),
|
||||
reviewAttentionCount: integer(
|
||||
summarySource.review_attention_count,
|
||||
"summary.review_attention_count",
|
||||
),
|
||||
reasonCounts,
|
||||
};
|
||||
const rawItems = array(source.items, "items");
|
||||
if (rawItems.length > 10_000) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"LiDAR local-surface review слишком большой",
|
||||
);
|
||||
}
|
||||
const items = rawItems.map((value, index): LidarLocalSurfaceReviewItem => {
|
||||
const item = record(value, `items[${index}]`);
|
||||
const attention = item.attention;
|
||||
if (attention !== "high" && attention !== "review") {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
`items[${index}].attention: несовместимое значение`,
|
||||
);
|
||||
}
|
||||
const reasons = array(item.reasons, `items[${index}].reasons`).map(
|
||||
(reason, reasonIndex): LidarLocalSurfaceReviewReason => {
|
||||
if (
|
||||
typeof reason !== "string"
|
||||
|| !REVIEW_REASONS.includes(
|
||||
reason as LidarLocalSurfaceReviewReason,
|
||||
)
|
||||
) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
`items[${index}].reasons[${reasonIndex}]: неизвестная причина`,
|
||||
);
|
||||
}
|
||||
return reason as LidarLocalSurfaceReviewReason;
|
||||
},
|
||||
);
|
||||
if (!reasons.length || new Set(reasons).size !== reasons.length) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
`items[${index}].reasons: несовместимый набор`,
|
||||
);
|
||||
}
|
||||
const predictionSource = record(
|
||||
item.prediction,
|
||||
`items[${index}].prediction`,
|
||||
);
|
||||
const temporalSource = record(
|
||||
item.temporal,
|
||||
`items[${index}].temporal`,
|
||||
);
|
||||
const surfaceSource = record(item.surface, `items[${index}].surface`);
|
||||
const predictionAvailable = boolean(
|
||||
predictionSource.available,
|
||||
`items[${index}].prediction.available`,
|
||||
);
|
||||
const temporalCompared = boolean(
|
||||
temporalSource.compared,
|
||||
`items[${index}].temporal.compared`,
|
||||
);
|
||||
const predictionInlierFraction = finite(
|
||||
predictionSource.inlier_fraction,
|
||||
`items[${index}].prediction.inlier_fraction`,
|
||||
);
|
||||
const predictionResidualP50M = finite(
|
||||
predictionSource.residual_p50_m,
|
||||
`items[${index}].prediction.residual_p50_m`,
|
||||
);
|
||||
const predictionResidualP95M = finite(
|
||||
predictionSource.residual_p95_m,
|
||||
`items[${index}].prediction.residual_p95_m`,
|
||||
);
|
||||
const heightDeltaM = finite(
|
||||
temporalSource.height_delta_m,
|
||||
`items[${index}].temporal.height_delta_m`,
|
||||
);
|
||||
const slopeDeltaDeg = finite(
|
||||
temporalSource.slope_delta_deg,
|
||||
`items[${index}].temporal.slope_delta_deg`,
|
||||
);
|
||||
const roughnessDeltaM = finite(
|
||||
temporalSource.roughness_delta_m,
|
||||
`items[${index}].temporal.roughness_delta_m`,
|
||||
);
|
||||
const surfaceConfidence = finite(
|
||||
surfaceSource.confidence,
|
||||
`items[${index}].surface.confidence`,
|
||||
);
|
||||
const attentionScore = finite(
|
||||
item.attention_score,
|
||||
`items[${index}].attention_score`,
|
||||
);
|
||||
const expectedReasons: LidarLocalSurfaceReviewReason[] = [];
|
||||
const expectedRatios: number[] = [];
|
||||
if (
|
||||
predictionAvailable
|
||||
&& predictionResidualP95M >= criteria.predictionTailResidualP95M
|
||||
) {
|
||||
expectedReasons.push("prediction-tail");
|
||||
expectedRatios.push(
|
||||
predictionResidualP95M / criteria.predictionTailResidualP95M,
|
||||
);
|
||||
}
|
||||
if (
|
||||
predictionAvailable
|
||||
&& predictionInlierFraction < criteria.predictionInlierFractionFloor
|
||||
) {
|
||||
expectedReasons.push("prediction-inlier-drop");
|
||||
expectedRatios.push(
|
||||
(1 - predictionInlierFraction)
|
||||
/ (1 - criteria.predictionInlierFractionFloor),
|
||||
);
|
||||
}
|
||||
if (temporalCompared && heightDeltaM >= criteria.surfaceHeightJumpM) {
|
||||
expectedReasons.push("surface-height-jump");
|
||||
expectedRatios.push(heightDeltaM / criteria.surfaceHeightJumpM);
|
||||
}
|
||||
if (temporalCompared && slopeDeltaDeg >= criteria.surfaceSlopeJumpDeg) {
|
||||
expectedReasons.push("surface-slope-jump");
|
||||
expectedRatios.push(slopeDeltaDeg / criteria.surfaceSlopeJumpDeg);
|
||||
}
|
||||
if (
|
||||
temporalCompared
|
||||
&& roughnessDeltaM >= criteria.surfaceRoughnessJumpM
|
||||
) {
|
||||
expectedReasons.push("surface-roughness-jump");
|
||||
expectedRatios.push(roughnessDeltaM / criteria.surfaceRoughnessJumpM);
|
||||
}
|
||||
const expectedAttentionScore = Math.max(...expectedRatios);
|
||||
if (
|
||||
predictionInlierFraction < 0
|
||||
|| predictionInlierFraction > 1
|
||||
|| surfaceConfidence < 0
|
||||
|| surfaceConfidence > 1
|
||||
|| attentionScore < 1
|
||||
|| (attention === "high")
|
||||
!== (attentionScore >= criteria.highAttentionScore)
|
||||
|| (
|
||||
reasons.some((reason) => reason.startsWith("prediction-"))
|
||||
&& !predictionAvailable
|
||||
)
|
||||
|| (
|
||||
reasons.some((reason) => reason.startsWith("surface-"))
|
||||
&& !temporalCompared
|
||||
)
|
||||
|| reasons.join("|") !== expectedReasons.join("|")
|
||||
|| !Number.isFinite(expectedAttentionScore)
|
||||
|| Math.abs(attentionScore - expectedAttentionScore) > 1e-9
|
||||
) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
`items[${index}]: attention evidence несовместим`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
rank: integer(item.rank, `items[${index}].rank`),
|
||||
frameIndex: integer(item.frame_index, `items[${index}].frame_index`),
|
||||
sourceFrameIndex: integer(
|
||||
item.source_frame_index,
|
||||
`items[${index}].source_frame_index`,
|
||||
),
|
||||
sessionSeconds: finite(
|
||||
item.session_seconds,
|
||||
`items[${index}].session_seconds`,
|
||||
),
|
||||
episodeId: text(
|
||||
item.episode_id,
|
||||
`items[${index}].episode_id`,
|
||||
SAFE_EPISODE_ID,
|
||||
),
|
||||
attention,
|
||||
attentionScore,
|
||||
reasons,
|
||||
prediction: {
|
||||
available: predictionAvailable,
|
||||
residualP50M: predictionResidualP50M,
|
||||
residualP95M: predictionResidualP95M,
|
||||
inlierFraction: predictionInlierFraction,
|
||||
},
|
||||
temporal: {
|
||||
compared: temporalCompared,
|
||||
heightDeltaM,
|
||||
slopeDeltaDeg,
|
||||
roughnessDeltaM,
|
||||
},
|
||||
surface: {
|
||||
sensorHeightM: finite(
|
||||
surfaceSource.sensor_height_m,
|
||||
`items[${index}].surface.sensor_height_m`,
|
||||
),
|
||||
slopeDeg: finite(
|
||||
surfaceSource.slope_deg,
|
||||
`items[${index}].surface.slope_deg`,
|
||||
),
|
||||
roughnessM: finite(
|
||||
surfaceSource.roughness_m,
|
||||
`items[${index}].surface.roughness_m`,
|
||||
),
|
||||
confidence: surfaceConfidence,
|
||||
},
|
||||
stepCandidatePointCount: integer(
|
||||
item.step_candidate_point_count,
|
||||
`items[${index}].step_candidate_point_count`,
|
||||
),
|
||||
};
|
||||
});
|
||||
const observedReasonCounts: Record<LidarLocalSurfaceReviewReason, number> = {
|
||||
"prediction-tail": 0,
|
||||
"prediction-inlier-drop": 0,
|
||||
"surface-height-jump": 0,
|
||||
"surface-slope-jump": 0,
|
||||
"surface-roughness-jump": 0,
|
||||
};
|
||||
for (const item of items) {
|
||||
for (const reason of item.reasons) observedReasonCounts[reason] += 1;
|
||||
}
|
||||
if (
|
||||
summary.itemCount !== items.length
|
||||
|| summary.highAttentionCount + summary.reviewAttentionCount
|
||||
!== summary.itemCount
|
||||
|| summary.highAttentionCount
|
||||
!== items.filter((item) => item.attention === "high").length
|
||||
|| summary.episodeCount !== new Set(items.map((item) => item.episodeId)).size
|
||||
|| !REVIEW_REASONS.every(
|
||||
(reason) => reasonCounts[reason] === observedReasonCounts[reason],
|
||||
)
|
||||
|| (!available && items.length > 0)
|
||||
|| items.some((item, index) =>
|
||||
item.rank !== index + 1
|
||||
|| (
|
||||
index > 0
|
||||
&& (
|
||||
item.attentionScore > items[index - 1].attentionScore
|
||||
|| (
|
||||
item.attentionScore === items[index - 1].attentionScore
|
||||
&& item.frameIndex < items[index - 1].frameIndex
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"LiDAR local-surface review content несовместим",
|
||||
);
|
||||
}
|
||||
return {
|
||||
reviewProfileId: REVIEW_PROFILE_ID,
|
||||
modelId: text(source.model_id, "model_id", SAFE_MODEL_ID),
|
||||
sourcePackId: text(source.source_pack_id, "source_pack_id", SAFE_PACK_ID),
|
||||
sessionId: text(source.session_id, "session_id", SAFE_ID),
|
||||
available,
|
||||
criteria,
|
||||
summary,
|
||||
items,
|
||||
groundTruth: false,
|
||||
authority: authority(source.authority),
|
||||
};
|
||||
}
|
||||
|
||||
async function responseJson(
|
||||
response: Response,
|
||||
fallback: string,
|
||||
@@ -1015,3 +1438,29 @@ export async function fetchLidarLocalSurfaceTimeline(
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchLidarLocalSurfaceReview(
|
||||
modelId: string,
|
||||
options: { signal?: AbortSignal; fetcher?: LidarFetch } = {},
|
||||
): Promise<LidarLocalSurfaceReview> {
|
||||
if (!SAFE_MODEL_ID.test(modelId)) {
|
||||
throw new LidarLocalSurfaceContractError(
|
||||
"Некорректный LiDAR local-surface review",
|
||||
);
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/lidar/local-surfaces/${modelId}/review`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseLidarLocalSurfaceReview(
|
||||
await responseJson(
|
||||
response,
|
||||
"Не удалось получить LiDAR local-surface review.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user