Skip to content

Commit 05aecd0

Browse files
authored
Merge pull request #25 from WildMeOrg/feat/pipeline-partial-success
feat: keep partial pipeline results when one species or detection fails
2 parents 7def253 + b3799bb commit 05aecd0

5 files changed

Lines changed: 388 additions & 75 deletions

File tree

__tests__/rntl/screens/CaptureScreen.test.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,33 @@ const MOCK_PIPELINE_RESULT = {
106106
observationId: 'obs-123',
107107
photoUri: 'file:///mock/camera.jpg',
108108
detections: [],
109+
errors: [],
109110
totalInferenceTimeMs: 150,
110111
};
111112

113+
const MOCK_DETECTION = {
114+
id: 'det-1',
115+
observationId: 'obs-123',
116+
boundingBox: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 },
117+
species: 'horse_wild',
118+
speciesConfidence: 0.9,
119+
croppedImageUri: 'file:///mock/crop.jpg',
120+
embedding: [0.1, 0.2],
121+
matchResult: {
122+
topCandidates: [],
123+
approvedIndividual: null,
124+
reviewStatus: 'pending',
125+
},
126+
encounterFields: {
127+
locationId: null,
128+
sex: null,
129+
lifeStage: null,
130+
behavior: null,
131+
submitterId: null,
132+
projectId: null,
133+
},
134+
};
135+
112136
describe('CaptureScreen', () => {
113137
beforeEach(() => {
114138
jest.clearAllMocks();
@@ -338,6 +362,56 @@ describe('CaptureScreen', () => {
338362
expect(call.speciesConfigs[0].packId).toBe('pack-compatible');
339363
});
340364

365+
// ==========================================================================
366+
// Partial-success handling
367+
// ==========================================================================
368+
369+
it('saves the observation and warns when some detections failed', async () => {
370+
(wildlifePipeline.processPhoto as jest.Mock).mockResolvedValue({
371+
...MOCK_PIPELINE_RESULT,
372+
detections: [MOCK_DETECTION],
373+
errors: [
374+
{ species: 'zebra_plains', stage: 'detector', message: 'ONNX error' },
375+
],
376+
});
377+
378+
const { getByTestId } = render(<CaptureScreen />);
379+
fireEvent.press(getByTestId('take-photo-button'));
380+
381+
await waitFor(() => {
382+
expect(mockNavigate).toHaveBeenCalledWith('DetectionResults', {
383+
observationId: 'obs-123',
384+
});
385+
});
386+
expect(useWildlifeStore.getState().observations).toHaveLength(1);
387+
expect(Alert.alert).toHaveBeenCalledWith(
388+
'Some detections failed',
389+
expect.stringContaining('zebra_plains'),
390+
);
391+
});
392+
393+
it('does not save an observation when everything failed', async () => {
394+
(wildlifePipeline.processPhoto as jest.Mock).mockResolvedValue({
395+
...MOCK_PIPELINE_RESULT,
396+
detections: [],
397+
errors: [
398+
{ species: null, stage: 'embedding-model', message: 'model corrupt' },
399+
],
400+
});
401+
402+
const { getByTestId } = render(<CaptureScreen />);
403+
fireEvent.press(getByTestId('take-photo-button'));
404+
405+
await waitFor(() => {
406+
expect(Alert.alert).toHaveBeenCalledWith(
407+
'Detection Failed',
408+
expect.stringContaining('model corrupt'),
409+
);
410+
});
411+
expect(useWildlifeStore.getState().observations).toHaveLength(0);
412+
expect(mockNavigate).not.toHaveBeenCalled();
413+
});
414+
341415
// ==========================================================================
342416
// Error Handling
343417
// ==========================================================================

__tests__/unit/services/wildlifePipeline.test.ts

Lines changed: 114 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -299,18 +299,121 @@ describe('WildlifePipeline', () => {
299299
expect(mockExtractEmbedding).toHaveBeenCalledTimes(2);
300300
});
301301

302-
it('should propagate errors when detector fails', async () => {
303-
mockRunDetection.mockRejectedValueOnce(new Error('Detector ONNX error'));
302+
it('should record a detector failure as an error and keep other species results', async () => {
303+
const detectionResult = {
304+
boundingBox: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 },
305+
species: 'giraffe',
306+
confidence: 0.9,
307+
};
308+
mockRunDetection
309+
.mockRejectedValueOnce(new Error('Detector ONNX error'))
310+
.mockResolvedValueOnce({ results: [detectionResult], inferenceTimeMs: 100 });
311+
mockExtractEmbedding.mockResolvedValue({
312+
embedding: [0.1, 0.2, 0.3],
313+
inferenceTimeMs: 50,
314+
});
315+
mockMatchEmbedding.mockReturnValue([]);
304316

305-
const config = makeSpeciesConfig();
306-
await expect(
307-
wildlifePipeline.processPhoto({
308-
photoUri: 'file:///photos/fail.jpg',
309-
310-
speciesConfigs: [config],
311-
miewidModelPath: '/models/miewid.onnx',
312-
}),
313-
).rejects.toThrow('Detector ONNX error');
317+
const result = await wildlifePipeline.processPhoto({
318+
photoUri: 'file:///photos/partial.jpg',
319+
speciesConfigs: [
320+
makeSpeciesConfig({ species: 'zebra_plains' }),
321+
makeSpeciesConfig({
322+
packId: 'pack-giraffe',
323+
species: 'giraffe',
324+
detectorModelPath: '/models/giraffe_detector.onnx',
325+
}),
326+
],
327+
miewidModelPath: '/models/miewid.onnx',
328+
});
329+
330+
expect(result.detections).toHaveLength(1);
331+
expect(result.detections[0].species).toBe('giraffe');
332+
expect(result.errors).toHaveLength(1);
333+
expect(result.errors[0]).toMatchObject({
334+
species: 'zebra_plains',
335+
stage: 'detector',
336+
message: expect.stringContaining('Detector ONNX error'),
337+
});
338+
});
339+
340+
it('should record a per-detection failure and keep the remaining detections', async () => {
341+
const makeDetection = (x: number) => ({
342+
boundingBox: { x, y: 0.2, width: 0.3, height: 0.4 },
343+
species: 'zebra_plains',
344+
confidence: 0.9,
345+
});
346+
mockRunDetection.mockResolvedValueOnce({
347+
results: [makeDetection(0.1), makeDetection(0.5)],
348+
inferenceTimeMs: 100,
349+
});
350+
mockExtractEmbedding
351+
.mockRejectedValueOnce(new Error('embedding blew up'))
352+
.mockResolvedValueOnce({ embedding: [0.1, 0.2, 0.3], inferenceTimeMs: 50 });
353+
mockMatchEmbedding.mockReturnValue([]);
354+
355+
const result = await wildlifePipeline.processPhoto({
356+
photoUri: 'file:///photos/two-animals.jpg',
357+
speciesConfigs: [makeSpeciesConfig()],
358+
miewidModelPath: '/models/miewid.onnx',
359+
});
360+
361+
expect(result.detections).toHaveLength(1);
362+
expect(result.errors).toHaveLength(1);
363+
expect(result.errors[0]).toMatchObject({
364+
species: 'zebra_plains',
365+
stage: 'embedding',
366+
message: expect.stringContaining('embedding blew up'),
367+
});
368+
});
369+
370+
it('should fail fast with an embedding-model error when MiewID cannot load', async () => {
371+
// MiewID is the first load attempt (fail-fast contract)
372+
mockIsModelLoaded.mockReturnValueOnce(false);
373+
mockLoadModel.mockRejectedValueOnce(new Error('MiewID file corrupt'));
374+
375+
const result = await wildlifePipeline.processPhoto({
376+
photoUri: 'file:///photos/test.jpg',
377+
speciesConfigs: [makeSpeciesConfig()],
378+
miewidModelPath: '/models/miewid.onnx',
379+
});
380+
381+
expect(result.detections).toHaveLength(0);
382+
expect(result.errors).toHaveLength(1);
383+
expect(result.errors[0]).toMatchObject({
384+
species: null,
385+
stage: 'embedding-model',
386+
message: expect.stringContaining('MiewID file corrupt'),
387+
});
388+
// No detector work is wasted when embeddings are impossible
389+
expect(mockRunDetection).not.toHaveBeenCalled();
390+
});
391+
392+
it('should return an empty errors array on full success', async () => {
393+
mockRunDetection.mockResolvedValueOnce({
394+
results: [
395+
{
396+
boundingBox: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 },
397+
species: 'zebra_plains',
398+
confidence: 0.9,
399+
},
400+
],
401+
inferenceTimeMs: 100,
402+
});
403+
mockExtractEmbedding.mockResolvedValueOnce({
404+
embedding: [0.1, 0.2, 0.3],
405+
inferenceTimeMs: 50,
406+
});
407+
mockMatchEmbedding.mockReturnValue([]);
408+
409+
const result = await wildlifePipeline.processPhoto({
410+
photoUri: 'file:///photos/test.jpg',
411+
speciesConfigs: [makeSpeciesConfig()],
412+
miewidModelPath: '/models/miewid.onnx',
413+
});
414+
415+
expect(result.errors).toEqual([]);
416+
expect(result.detections).toHaveLength(1);
314417
});
315418

316419
it('should accumulate inference time from detection and embedding', async () => {

src/screens/CaptureScreen/useCaptureFlow.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,17 @@ export function useCaptureFlow() {
163163
miewidModelPath: miewidModel.path,
164164
});
165165

166+
// Total failure: nothing completed, nothing worth saving.
167+
if (result.detections.length === 0 && result.errors.length > 0) {
168+
Alert.alert(
169+
'Detection Failed',
170+
result.errors
171+
.map((e) => (e.species ? `${e.species}: ${e.message}` : e.message))
172+
.join('\n'),
173+
);
174+
return;
175+
}
176+
166177
// Save observation to store
167178
useWildlifeStore.getState().addObservation({
168179
id: result.observationId,
@@ -178,6 +189,17 @@ export function useCaptureFlow() {
178189
navigation.navigate('DetectionResults', {
179190
observationId: result.observationId,
180191
});
192+
193+
// Partial failure: the observation is saved with what completed;
194+
// tell the user what was lost.
195+
if (result.errors.length > 0) {
196+
Alert.alert(
197+
'Some detections failed',
198+
result.errors
199+
.map((e) => (e.species ? `${e.species}: ${e.message}` : e.message))
200+
.join('\n'),
201+
);
202+
}
181203
} catch (error) {
182204
const message =
183205
error instanceof Error ? error.message : 'Unknown error';

0 commit comments

Comments
 (0)