refactor: move container bias handling into subflows

This commit is contained in:
Lucio Lelii 2026-07-23 18:50:43 +02:00
parent 47dc3b3c3b
commit c2ae39f292
8 changed files with 46 additions and 221 deletions

View File

@ -39,8 +39,6 @@ public class Container<T extends ContainerType> implements FlowNode {
List<IODescriptor> outputs;
List<BlockBiasAnnotation> biasAnnotations = List.of();
String laneId;
ContainerConfiguration<T> specificConfiguration;
@ -61,21 +59,27 @@ public class Container<T extends ContainerType> implements FlowNode {
return type;
}
/**
* Containers are not bias-annotatable: bias annotations live only on the
* nodes of the inner subflow and are activated via {@code includeSubflow}
* on a bias rerun. Always returns an empty list.
*/
@Override
@JsonIgnore
public List<BlockBiasAnnotation> getBiasAnnotations() {
return biasAnnotations == null ? List.of() : biasAnnotations;
return List.of();
}
@Builder
public Container(@NonNull ContainerConfiguration<T> specificConfiguration, @Singular List<IODescriptor> inputs,
@Singular List<IODescriptor> outputs,
@Singular("biasAnnotation") List<BlockBiasAnnotation> biasAnnotations, T type,
T type,
@JsonProperty("typeName") @JsonAlias("type") String resolvedTypeName,
Position position, String laneId) {
this.specificConfiguration = specificConfiguration;
this.name = specificConfiguration.getName();
this.inputs = inputs;
this.outputs = outputs;
this.biasAnnotations = biasAnnotations == null ? List.of() : List.copyOf(biasAnnotations);
this.typeName = type != null ? type.getName() : resolvedTypeName;
this.position = position;
this.type = type != null ? type : resolveContainerType(this.typeName);

View File

@ -36,7 +36,6 @@ public class BiasAnnotationsController {
public record BiasAnnotationDescriptor(
String type,
String blockProperty,
String containerProperty,
boolean multiple,
int maxItems,
JsonNode schema,
@ -61,7 +60,6 @@ public class BiasAnnotationsController {
return new BiasAnnotationDescriptor(
BlockBiasAnnotation.class.getSimpleName(),
"biasAnnotations",
"biasAnnotations",
true,
BlockBiasAnnotation.MAX_PER_BLOCK,
schemaProducer.generateSchemaNode(BlockBiasAnnotation.class),

View File

@ -41,6 +41,7 @@ public class ContainerBiasCapabilitiesController {
boolean fullFlowExperimentSupported,
boolean externalSideEffects,
boolean configurationDependent,
boolean subflowPropagationSupported,
List<BiasActivationMode> activationModes) {
}
@ -69,14 +70,19 @@ public class ContainerBiasCapabilitiesController {
}
private ContainerBiasCapabilityDescriptor descriptor(Container<?> container) {
// Containers are not bias-annotatable at their boundary: they carry no
// activation modes of their own. They still participate in a full-flow
// bias rerun via subflow propagation (includeSubflow), which activates
// the executable annotations on their inner subflow nodes.
List<BiasActivationMode> modes = adapterRegistry.supportedModes(container).stream().sorted().toList();
return new ContainerBiasCapabilityDescriptor(
container.getType().getName(),
!modes.isEmpty(),
true,
false,
!modes.isEmpty(),
true,
false,
false,
true,
modes);
}

View File

@ -6,6 +6,7 @@ import java.util.Set;
import org.springframework.stereotype.Component;
import it.cnr.isti.workflow.manager.blocks.Block;
import it.cnr.isti.workflow.manager.executions.steps.Input;
import it.cnr.isti.workflow.manager.flows.model.FlowNode;
import it.cnr.isti.workflow.manager.flows.model.bias.BiasActivationMode;
@ -17,7 +18,7 @@ public class InputBiasBehaviorAdapter implements BiasBehaviorAdapter {
@Override
public boolean supports(FlowNode node, BiasActivationMode mode) {
return mode == BiasActivationMode.INPUT_TRANSFORMATION;
return mode == BiasActivationMode.INPUT_TRANSFORMATION && node instanceof Block<?>;
}
@Override
@ -37,6 +38,6 @@ public class InputBiasBehaviorAdapter implements BiasBehaviorAdapter {
@Override
public Set<BiasActivationMode> supportedModes(FlowNode node) {
return Set.of(BiasActivationMode.INPUT_TRANSFORMATION);
return node instanceof Block<?> ? Set.of(BiasActivationMode.INPUT_TRANSFORMATION) : Set.of();
}
}

View File

@ -4,6 +4,7 @@ import java.util.Set;
import org.springframework.stereotype.Component;
import it.cnr.isti.workflow.manager.blocks.Block;
import it.cnr.isti.workflow.manager.flows.model.FlowNode;
import it.cnr.isti.workflow.manager.flows.model.bias.BiasActivationMode;
import it.cnr.isti.workflow.manager.flows.model.bias.BlockBiasAnnotation;
@ -13,7 +14,7 @@ public class OutputBiasBehaviorAdapter implements BiasBehaviorAdapter {
@Override
public boolean supports(FlowNode node, BiasActivationMode mode) {
return mode == BiasActivationMode.OUTPUT_TRANSFORMATION;
return mode == BiasActivationMode.OUTPUT_TRANSFORMATION && node instanceof Block<?>;
}
@Override
@ -23,6 +24,6 @@ public class OutputBiasBehaviorAdapter implements BiasBehaviorAdapter {
@Override
public Set<BiasActivationMode> supportedModes(FlowNode node) {
return Set.of(BiasActivationMode.OUTPUT_TRANSFORMATION);
return node instanceof Block<?> ? Set.of(BiasActivationMode.OUTPUT_TRANSFORMATION) : Set.of();
}
}

View File

@ -30,6 +30,8 @@ public record NodeTypeCapabilities(
}
public static NodeTypeCapabilities container() {
return new NodeTypeCapabilities(NodeVisualRole.CONTAINER, false, true, true, true, true, true);
// biasAnnotationsAllowed = false: containers are not bias-annotatable;
// bias lives only on the inner subflow nodes (activated via includeSubflow).
return new NodeTypeCapabilities(NodeVisualRole.CONTAINER, false, false, true, true, true, true);
}
}

View File

@ -23,8 +23,6 @@ import it.cnr.isti.workflow.manager.containers.types.GenericContainerType;
import it.cnr.isti.workflow.manager.controllers.BiasAnnotationsController.BiasAnnotationDescriptor;
import it.cnr.isti.workflow.manager.flows.model.bias.BiasAnnotationSource;
import it.cnr.isti.workflow.manager.flows.model.bias.BiasAnnotationStatus;
import it.cnr.isti.workflow.manager.flows.model.bias.BiasActivationMode;
import it.cnr.isti.workflow.manager.flows.model.bias.BiasBehavioralProbe;
import it.cnr.isti.workflow.manager.flows.model.bias.BiasCategory;
import it.cnr.isti.workflow.manager.flows.model.bias.BiasSeverity;
import it.cnr.isti.workflow.manager.flows.model.bias.BlockBiasAnnotation;
@ -50,9 +48,6 @@ class BiasAnnotationsControllerTest {
@Autowired
GenericContainerFactory genericContainerFactory;
@Autowired
GenericContainerType genericContainerType;
@Autowired
FlowService flowService;
@ -62,7 +57,6 @@ class BiasAnnotationsControllerTest {
assertEquals(BlockBiasAnnotation.class.getSimpleName(), descriptor.type());
assertEquals("biasAnnotations", descriptor.blockProperty());
assertEquals("biasAnnotations", descriptor.containerProperty());
assertTrue(descriptor.multiple());
assertEquals(BlockBiasAnnotation.MAX_PER_BLOCK, descriptor.maxItems());
assertEquals("PROPOSED", descriptor.defaults().get("status"));
@ -138,27 +132,24 @@ class BiasAnnotationsControllerTest {
}
@Test
void biasAnnotationRoundTripsWithContainerJson() throws Exception {
BlockBiasAnnotation annotation = new BlockBiasAnnotation(
null,
BiasCategory.SELECTION_BIAS,
BiasSeverity.HIGH,
"The subflow may discard relevant candidates.",
null,
"Review the subflow boundary inputs and outputs.",
BiasAnnotationStatus.CONFIRMED,
BiasAnnotationSource.MANUAL,
null,
new BiasBehavioralProbe(BiasActivationMode.INPUT_TRANSFORMATION,
"biased:${original}", java.util.List.of(), "Boundary input changes"));
Container<GenericContainerType> container = annotatedContainer(annotation);
void containerDropsBiasAnnotationsFromJson() throws Exception {
// Containers are not bias-annotatable: any biasAnnotations present in
// incoming JSON must be ignored, and getBiasAnnotations() stays empty.
Container<GenericContainerType> container = genericContainerFactory.create(
GenericContainerConfiguration.builder().name("Container")
.subFlow(FlowData.builder().build()).build());
ObjectNode json = ObjectMapperHolder.mapper.valueToTree(container);
assertFalse(json.has("biasAnnotations"));
var annotations = json.putArray("biasAnnotations");
ObjectNode annotationNode = annotations.addObject();
annotationNode.put("category", BiasCategory.SELECTION_BIAS.name());
annotationNode.put("severity", BiasSeverity.HIGH.name());
annotationNode.put("issue", "Should be ignored on a container.");
String json = ObjectMapperHolder.mapper.writeValueAsString(container);
Container<?> restored = ObjectMapperHolder.mapper.readValue(json, Container.class);
Container<?> restored = ObjectMapperHolder.mapper.treeToValue(json, Container.class);
assertEquals(1, restored.getBiasAnnotations().size());
assertEquals(BiasCategory.SELECTION_BIAS, restored.getBiasAnnotations().getFirst().category());
assertEquals(annotation.id(), restored.getBiasAnnotations().getFirst().id());
assertNotNull(restored.getBiasAnnotations());
assertTrue(restored.getBiasAnnotations().isEmpty());
}
@Test
@ -226,57 +217,6 @@ class BiasAnnotationsControllerTest {
assertTrue(exception.getMessage().contains("BIAS_CATEGORY_REQUIRED"));
}
@Test
void annotatedContainerIsValidatedAndPersistedInsideFlowJson() {
BlockBiasAnnotation annotation = new BlockBiasAnnotation(
null,
BiasCategory.TRANSPARENCY_RISK,
BiasSeverity.MEDIUM,
"The nested process is opaque.",
null,
"Expose the nested process rationale.",
BiasAnnotationStatus.MITIGATED,
BiasAnnotationSource.MANUAL,
null,
null);
FlowCreateRequest request = new FlowCreateRequest(
"Container bias persistence test",
"Flow with a container bias annotation",
FlowData.builder().container(annotatedContainer(annotation)).build());
FlowView created = flowService.createFlow("bias-container-test-user", request);
FlowView restored = flowService.getFlow(created.id(), "bias-container-test-user");
assertEquals(1, restored.flow().getContainers().getFirst().getBiasAnnotations().size());
assertEquals(BiasCategory.TRANSPARENCY_RISK,
restored.flow().getContainers().getFirst().getBiasAnnotations().getFirst().category());
}
@Test
void flowValidationRejectsUnsupportedContainerProbe() {
BlockBiasAnnotation annotation = new BlockBiasAnnotation(
null,
BiasCategory.AUTOMATION_BIAS,
BiasSeverity.MEDIUM,
"Invalid prompt probe on a container.",
null,
null,
null,
null,
null,
new BiasBehavioralProbe(BiasActivationMode.PROMPT_DIRECTIVE,
"Prefer the initial assumption", java.util.List.of(), null));
FlowCreateRequest request = new FlowCreateRequest(
"Invalid container probe test",
null,
FlowData.builder().container(annotatedContainer(annotation)).build());
RuntimeException exception = assertThrows(RuntimeException.class,
() -> flowService.createFlow("bias-container-test-user", request));
assertTrue(exception.getMessage().contains("BIAS_PROBE_MODE_UNSUPPORTED"));
assertTrue(exception.getMessage().contains("container"));
}
private Block<LLMBlockType> annotatedBlock(BlockBiasAnnotation annotation) {
LLMBlockConfiguration configuration = LLMBlockConfiguration.builder()
@ -294,19 +234,4 @@ class BiasAnnotationsControllerTest {
.build();
}
private Container<GenericContainerType> annotatedContainer(BlockBiasAnnotation annotation) {
GenericContainerConfiguration configuration = GenericContainerConfiguration.builder()
.name("Annotated container")
.subFlow(FlowData.builder().build())
.build();
Container<GenericContainerType> canonical = genericContainerFactory.create(configuration);
return Container.<GenericContainerType>builder()
.specificConfiguration(configuration)
.inputs(canonical.getInputs())
.outputs(canonical.getOutputs())
.type(genericContainerType)
.position(canonical.getPosition())
.biasAnnotation(annotation)
.build();
}
}

View File

@ -37,9 +37,6 @@ import it.cnr.isti.workflow.manager.executions.ExecutionObject;
import it.cnr.isti.workflow.manager.executions.ExecutionStatus;
import it.cnr.isti.workflow.manager.executions.ExecutionsService;
import it.cnr.isti.workflow.manager.executions.executors.NodeExecutors;
import it.cnr.isti.workflow.manager.executions.bias.runtime.BiasBehaviorAdapterRegistry;
import it.cnr.isti.workflow.manager.executions.bias.runtime.BiasPreparedExecution;
import it.cnr.isti.workflow.manager.executions.steps.Input;
import it.cnr.isti.workflow.manager.flows.model.FlowData;
import it.cnr.isti.workflow.manager.flows.model.bias.BiasActivationMode;
import it.cnr.isti.workflow.manager.flows.model.bias.BiasAnnotationSource;
@ -51,8 +48,6 @@ import it.cnr.isti.workflow.manager.flows.model.bias.BlockBiasAnnotation;
import it.cnr.isti.workflow.manager.llms.LLMDescriptor;
import it.cnr.isti.workflow.manager.llms.providers.LLMProvider;
import it.cnr.isti.workflow.manager.flows.validation.ValidationErrorCode;
import it.cnr.isti.workflow.manager.ios.IODescriptor;
import it.cnr.isti.workflow.manager.ios.IOType;
@SpringBootTest
@TestPropertySource(locations = "classpath:test.properties")
@ -116,9 +111,6 @@ class BiasExperimentsIntegrationTest {
@Autowired
ContainerBiasCapabilitiesController containerCapabilitiesController;
@Autowired
GenericContainerType genericContainerType;
@Autowired
GenericContainerFactory genericContainerFactory;
@ -156,100 +148,10 @@ class BiasExperimentsIntegrationTest {
assertTrue(containerCapabilities.supported());
assertFalse(containerCapabilities.isolatedExperimentSupported());
assertTrue(containerCapabilities.fullFlowExperimentSupported());
assertEquals(List.of(BiasActivationMode.INPUT_TRANSFORMATION, BiasActivationMode.OUTPUT_TRANSFORMATION),
containerCapabilities.activationModes());
}
@Test
void containerBoundaryAppliesInputAndOutputTransformations() {
BlockBiasAnnotation inputAnnotation = annotation(
"container-input-bias",
new BiasBehavioralProbe(BiasActivationMode.INPUT_TRANSFORMATION,
"input:${original}", List.of("payload"), "Input is transformed"));
BlockBiasAnnotation outputAnnotation = annotation(
"container-output-bias",
new BiasBehavioralProbe(BiasActivationMode.OUTPUT_TRANSFORMATION,
"output:${original}", List.of(), "Output is transformed"));
Container<GenericContainerType> container = Container.<GenericContainerType>builder()
.specificConfiguration(GenericContainerConfiguration.builder()
.name("Biased boundary")
.subFlow(FlowData.builder().build())
.build())
.input(IODescriptor.of("payload", IOType.TEXT))
.output(IODescriptor.of("result", IOType.TEXT))
.type(genericContainerType)
.biasAnnotation(inputAnnotation)
.biasAnnotation(outputAnnotation)
.build();
BiasExecutionContext context = new BiasExecutionContext(
"container-experiment",
BiasExecutionMode.BIAS_VARIANT,
Map.of(container.getId(), List.of(inputAnnotation.id(), outputAnnotation.id())),
ExternalSideEffectPolicy.BLOCK,
false);
BiasPreparedExecution prepared = BiasBehaviorAdapterRegistry.prepare(
container,
List.of(Input.detached(container.getInputs().getFirst(), "candidate")),
Map.of(),
context,
null);
Map<String, Object> completed = BiasBehaviorAdapterRegistry.complete(prepared, Map.of("result", "accepted"));
assertEquals("input:candidate", prepared.getInputs().getFirst().getValue());
assertEquals("output:accepted", completed.get("result"));
}
@Test
void fullFlowBiasRerunActivatesContainerAnnotation() {
BlockBiasAnnotation annotation = annotation(
"container-full-flow-bias",
new BiasBehavioralProbe(BiasActivationMode.OUTPUT_TRANSFORMATION,
"container:${original}", List.of(), "Container output is transformed"));
LLMBlockConfiguration innerConfiguration = LLMBlockConfiguration.builder()
.name("Inner decision")
.prompt("Return a decision")
.llmDescriptor(LLMDescriptor.builder()
.provider("biasExperimentProvider")
.model("bias-test-model")
.build())
.build();
Block<LLMBlockType> innerBlock = llmBlockFactory.create(innerConfiguration);
GenericContainerConfiguration containerConfiguration = GenericContainerConfiguration.builder()
.name("Annotated process")
.subFlow(FlowData.builder().block(innerBlock).build())
.build();
Container<GenericContainerType> canonical = genericContainerFactory.create(containerConfiguration);
Container<GenericContainerType> container = Container.<GenericContainerType>builder()
.specificConfiguration(containerConfiguration)
.inputs(canonical.getInputs())
.outputs(canonical.getOutputs())
.type(genericContainerType)
.biasAnnotation(annotation)
.build();
ExecutionObject baseline = executionsService.createExecutionForFlow(
"container-bias-flow",
"Container bias flow",
FlowData.builder().container(container).build(),
OWNER);
executionsService.startExecution(baseline.getId());
waitUntilFinal(baseline);
assertEquals(ExecutionStatus.SUCCESS, baseline.getContext().getStatus());
ExecutionObject variant = executionsService.createBiasRerun(
baseline.getId(),
OWNER,
new BiasRerunRequest(
List.of(new BiasActivation(container.getId(), List.of(annotation.id()))),
ExternalSideEffectPolicy.BLOCK,
false));
executionsService.startExecution(variant.getId());
waitUntilFinal(variant);
assertEquals(ExecutionStatus.SUCCESS, variant.getContext().getStatus());
BiasImpactReport report = biasImpactService.compareFullFlow(baseline.getId(), variant.getId(), true, OWNER);
assertTrue(report.immediateImpact().outputChanged());
assertEquals(container.getId(), report.nodeId());
// Containers are not bias-annotatable at their boundary: no activation
// modes of their own; bias participation is via subflow propagation.
assertTrue(containerCapabilities.subflowPropagationSupported());
assertTrue(containerCapabilities.activationModes().isEmpty());
}
@Test
@ -567,20 +469,6 @@ class BiasExperimentsIntegrationTest {
return annotation(mode, instruction, targetInputs, Map.of());
}
private BlockBiasAnnotation annotation(String id, BiasBehavioralProbe probe) {
return new BlockBiasAnnotation(
id,
BiasCategory.CONFIRMATION_BIAS,
BiasSeverity.HIGH,
"The node may reinforce an initial conclusion.",
"The output can underweight contrary evidence.",
"Require independent evidence.",
BiasAnnotationStatus.CONFIRMED,
BiasAnnotationSource.MANUAL,
null,
probe);
}
private BlockBiasAnnotation annotation(BiasActivationMode mode, String instruction, List<String> targetInputs,
Map<String, Object> mockOutputs) {
return new BlockBiasAnnotation(