Bug Description
The handling of "empty" views is currently done at app level, on a case-by-case basis, with no guidance from the SDK. Some apps skip creating a view entirely when they have nothing to annotate; others create a view but leave it without annotations. Neither behavior is documented or enforced, so the actual behavior of any given app is whatever its author happened to write.
Definition of "empty"
A view is empty when it carries zero annotations, regardless of whether its contains metadata was populated. This includes two distinct situations that both need to be treated as "empty":
- no view was created at all for a given
_annotate() call
- one or more views were created,
new_contain() was called on them, but no Annotation objects were ever added
Note that Mmif.sanitize() already strips any contains entry with zero matching annotations before serialization, so an empty-in-sense-2 view serializes with contains: {} regardless of what was declared. A view that has an app-set error or warnings in its metadata is not "empty" in this sense, even if it carries zero annotations: it is already informative on its own and must not be treated the same way.
Impact
Downstream tooling that uses app output might rely on the workflow ID generation offered by the mmif-python SDK (mmif.utils.workflow_helper.generate_param_hash), based on app identifier plus parameter hash pulled from view metadata. When an app returns no view at all, the last view is still whatever the previous app produced, so the workflow identifier computed for this app's (missing) output ends up identical to the identifier for the pipeline one step earlier, i.e. the pipeline minus the current app. Any downstream consumer that relies on this identifier hits the same problem: two distinct pipeline stages resolve to the same ID, so whatever the consumer does with that ID (storage keying, caching, deduplication, ...) silently misattributes one stage's identity to another. mmif-storage in aapb-brandeis-datahousing is one such consumer: it keys stored output by this identifier, so the collision causes one output to be dropped or overwritten with no error raised.
Reproduction steps
Confirmed case: app-spacy-wrapper creates one view per input TextDocument inside a loop (app.py, _annotate()). When the input MMIF has zero TextDocuments, the loop body never runs, so _annotate() returns the input MMIF completely unchanged: no new view, no record that the app ran at all.
Expected behavior
Or more like proposed behavior
Move this policy to the SDK level, in ClamsApp.annotate(), so it applies uniformly regardless of what an individual _annotate() implementation does:
- after
_annotate() returns, diff the new views (created during this call) against the views present before the call
- delete any new view that is empty, per the definition above (zero annotations, no app-set error, no app-set warnings)
- if every new view was deleted (including the case where none were created in the first place), add a single SDK-generated, signed warning view stating that the app ran but produced no annotations, and that the cause is unknown (could be missing/unsuitable input, could be a genuine absence of anything to annotate)
This guarantees that every annotate() call produces at least one view carrying that app's identity in its metadata, addressing the reproducibility issue. An app that partially succeeds (some inputs yield real output, others don't) keeps its real views untouched and gets no spurious warning; only a fully empty run gets the synthesized warning view.
Log output
Screenshots
No response
Additional context
No response
Bug Description
The handling of "empty" views is currently done at app level, on a case-by-case basis, with no guidance from the SDK. Some apps skip creating a view entirely when they have nothing to annotate; others create a view but leave it without annotations. Neither behavior is documented or enforced, so the actual behavior of any given app is whatever its author happened to write.
Definition of "empty"
A view is empty when it carries zero annotations, regardless of whether its
containsmetadata was populated. This includes two distinct situations that both need to be treated as "empty":_annotate()callnew_contain()was called on them, but noAnnotationobjects were ever addedNote that
Mmif.sanitize()already strips anycontainsentry with zero matching annotations before serialization, so an empty-in-sense-2 view serializes withcontains: {}regardless of what was declared. A view that has an app-seterrororwarningsin its metadata is not "empty" in this sense, even if it carries zero annotations: it is already informative on its own and must not be treated the same way.Impact
Downstream tooling that uses app output might rely on the workflow ID generation offered by the mmif-python SDK (
mmif.utils.workflow_helper.generate_param_hash), based on app identifier plus parameter hash pulled from view metadata. When an app returns no view at all, the last view is still whatever the previous app produced, so the workflow identifier computed for this app's (missing) output ends up identical to the identifier for the pipeline one step earlier, i.e. the pipeline minus the current app. Any downstream consumer that relies on this identifier hits the same problem: two distinct pipeline stages resolve to the same ID, so whatever the consumer does with that ID (storage keying, caching, deduplication, ...) silently misattributes one stage's identity to another.mmif-storagein aapb-brandeis-datahousing is one such consumer: it keys stored output by this identifier, so the collision causes one output to be dropped or overwritten with no error raised.Reproduction steps
Confirmed case:
app-spacy-wrappercreates one view per inputTextDocumentinside a loop (app.py,_annotate()). When the input MMIF has zeroTextDocuments, the loop body never runs, so_annotate()returns the input MMIF completely unchanged: no new view, no record that the app ran at all.Expected behavior
Or more like proposed behavior
Move this policy to the SDK level, in
ClamsApp.annotate(), so it applies uniformly regardless of what an individual_annotate()implementation does:_annotate()returns, diff the new views (created during this call) against the views present before the callThis guarantees that every
annotate()call produces at least one view carrying that app's identity in its metadata, addressing the reproducibility issue. An app that partially succeeds (some inputs yield real output, others don't) keeps its real views untouched and gets no spurious warning; only a fully empty run gets the synthesized warning view.Log output
Screenshots
No response
Additional context
No response