Tesseract 0.28.4
Loading...
Searching...
No Matches
Derived Type Support in PropertyTree

Comprehensive guide to using derived types with the plugin info structure.

Overview

PropertyTree provides comprehensive support for derived types in configuration validation. This feature enables extensible, inheritance-based configuration hierarchies where fields can accept any properly registered derived type of a base type.

Key Features

  • Heterogeneous Collections: Arrays and maps can contain different derived types
  • Dynamic Registration: Loading a plugin library can register new schemas without changing the host schema
  • Plugin Pattern: Uses the standard Tesseract "class" + "config" plugin info structure
  • Type Safety: Validation ensures types are properly registered and configs are valid
  • Clear Error Messages: Reports invalid types with available options

The implementation uses the standard Tesseract plugin info structure (class + config) to cleanly separate type identification from configuration:

field:
class: DerivedTypeIdentifier # Type identifier (required)
config: # Type-specific config (optional)
param1: value1
param2: value2

This aligns with the PluginInfo struct used throughout Tesseract for extensible components.

Quick Start

Here's the three-step process to use derived types:

Step 1: Register Types

PropertyTree baseConstraintSchema()
{
return PropertyTreeBuilder()
.attribute(property_attribute::TYPE, property_type::CONTAINER)
.float64("weight").defaultVal(1.0).done()
.build();
}
PropertyTree jointPositionConstraintSchema()
{
return PropertyTreeBuilder()
.attribute(property_attribute::TYPE, property_type::CONTAINER)
.float64("weight").defaultVal(1.0).done()
.string("joint").required().done()
.float64("position").required().done()
.build();
}
// Register schema-producing functions. The macro stringizes each key.
TESSERACT_SCHEMA_REGISTER(BaseConstraint, baseConstraintSchema);
TESSERACT_SCHEMA_REGISTER(JointPositionConstraint, jointPositionConstraintSchema);
// Register inheritance relationships
TESSERACT_SCHEMA_REGISTER_DERIVED_TYPE(BaseConstraint, JointPositionConstraint);
#define TESSERACT_SCHEMA_REGISTER(KEY, SCHEMA_SOURCE)
Macro to register either a file‐based schema or a function‐built schema.
Definition schema_registration.h:64
#define TESSERACT_SCHEMA_REGISTER_DERIVED_TYPE(BASE_TYPE, DERIVED_TYPE)
Macro to register that a derived type can be used where a base type is expected.
Definition schema_registration.h:85

Step 2: Define Schema with acceptsDerivedTypes()

auto schema = PropertyTreeBuilder()
.container("constraints")
.customType("list", "List[BaseConstraint]")
.acceptsDerivedTypes() // Enable plugin info structure validation
.done()
.done()
.build();

Step 3: Use Plugin Info Structure in YAML

constraints:
- class: JointPositionConstraint
config:
joint: "joint_1"
position: 0.785
- class: BaseConstraint
config:
weight: 1.0
That's it! The schema will validate that each element has a valid class name and config values.

Strict Registration Contract

Derived-type validation is intentionally strict. A type is usable only when both its concrete schema and its relationship to the declared base type are registered. Registering only the relationship is an error; validation does not fall back to an empty schema. This guarantees that a plugin's config is checked rather than silently accepted.

Plugin loaders must load the libraries named by search_libraries before validating the complete configuration so that static schema registrations have executed. Libraries that contribute schemas must remain loaded while registry entries can be used because schemas may contain validator callbacks implemented in those libraries.

TESSERACT_SCHEMA_REGISTER(KEY, SCHEMA_SOURCE) stringizes KEY and accepts either a schema-producing function or a schema file path as SCHEMA_SOURCE. TESSERACT_SCHEMA_REGISTER_DERIVED_TYPE(BASE_TYPE, DERIVED_TYPE) stringizes both type names and records their compatibility. Place both macros at file scope in a .cpp file. Their static initializers run when the executable or plugin library is loaded. Registration order is not significant provided the schema and relationship both exist before validation starts.

PluginInfoContainer schemas require a plugins map. An empty container is represented explicitly as:

plugins: {}

Plugin Discovery and Library Lifetime

Plugin schemas are commonly registered by static initializers inside shared libraries. The complete plugin configuration therefore cannot be validated until the libraries named by that same configuration have been loaded. Tesseract resolves this bootstrap dependency with two validation stages.

PluginDiscoveryInfo contains the optional search_paths and search_libraries string lists. The complete ProfilesPluginInfo, KinematicsPluginInfo, ContactManagersPluginInfo, and TaskComposerPluginInfo value types derive from it, while their schemas compose the same discovery fields into the existing flat YAML representation.

Two-Stage Validation

  1. Validate discovery metadata: Merge the complete plugin YAML into the PluginDiscoveryInfo schema with extra properties allowed. This validates only search_paths and search_libraries; plugin-specific fields are intentionally ignored during this bootstrap pass.
  2. Load and retain libraries: Configure a candidate boost_plugin_loader::PluginLoader, then call SchemaRegistry::loadAndRetainPluginLibraries(). Loading executes static schema registrations. The registry keeps independent lifetime tokens, deduplicated by resolved library path.
  3. Validate the complete configuration: Merge a fresh clone into the complete schema with extra properties disallowed, then validate strictly.
  4. Decode and commit: Decode from an unmodified YAML snapshot and commit the candidate loader and plugin data only after complete validation succeeds.
YAML::Node snapshot = YAML::Clone(plugin_config);
// Stage 1: validate only metadata needed to discover schemas.
auto discovery_tree = YAML::convert<PluginDiscoveryInfo>::schema();
discovery_tree.mergeConfig(YAML::Clone(snapshot), true);
auto errors = discovery_tree.validate(true);
if (!errors.empty())
throw std::runtime_error("Plugin discovery validation failed");
const auto discovery = snapshot.as<PluginDiscoveryInfo>();
boost_plugin_loader::PluginLoader candidate_loader = current_loader;
candidate_loader.search_paths.insert(candidate_loader.search_paths.end(),
discovery.search_paths.begin(), discovery.search_paths.end());
candidate_loader.search_libraries.insert(candidate_loader.search_libraries.end(),
discovery.search_libraries.begin(), discovery.search_libraries.end());
// Deduplicate candidate_loader search paths and libraries before loading.
SchemaRegistry::instance()->loadAndRetainPluginLibraries(candidate_loader);
// Stage 2: reject unknown keys and validate plugin-specific config schemas.
auto config_tree = YAML::convert<CompletePluginInfo>::schema();
config_tree.mergeConfig(YAML::Clone(snapshot), false);
errors = config_tree.validate(false);
if (!errors.empty())
throw std::runtime_error("Complete plugin configuration validation failed");
auto complete_info = snapshot.as<CompletePluginInfo>();
current_loader = std::move(candidate_loader);

The candidate loader gives the factory local transaction semantics: failed complete validation does not modify its search paths, libraries, or decoded plugin maps. Loading a library can still add process-wide schema registrations; those global registrations and retained library handles are intentionally not rolled back.

Why the Registry Retains Libraries

A PropertyTree schema can contain std::function validators whose executable code resides in a plugin library. Unloading that library while the schema remains registered would leave invalid callbacks. The registry therefore destroys registered schemas before releasing its retained library lifetime tokens. Callers do not need to retain the candidate loader merely to keep registered schema callbacks valid.

Common Patterns

Single Entry

.customType("constraint", "BaseConstraint")
.acceptsDerivedTypes()
constraint:
class: JointPositionConstraint
config:
joint: "joint_1"
position: 0.785

Array/List

.customType("constraints", "List[BaseConstraint]")
.acceptsDerivedTypes()
constraints:
- class: JointPositionConstraint
config: { joint: "joint_1", position: 0.0 }
- class: CollisionConstraint
config: { constraint_type: "USE_LIMITS" }

Map

.customType("named_constraints", "Map[String,BaseConstraint]")
.acceptsDerivedTypes()
named_constraints:
home_position:
class: JointPositionConstraint
config: { joint: "wrist_3", position: 1.57 }
safety_limit:
class: CollisionConstraint
config: { constraint_type: "USE_LIMITS" }

Architecture

Key Components

  1. validatePluginInfo()
    • Validates individual plugin info structures (class + config).
    • Checks that class identifies a compatible derived type.
    • Requires the concrete class schema to be registered.
    • Validates config against the derived type's schema.
    • Provides hierarchical error messages.
  2. validateCustomType()
    • Detects the acceptsDerivedTypes() attribute.
    • Uses plugin info structure validation when enabled.
    • Iterates single entries, sequences, and maps while adding element paths.
    • Routes configurations containing the legacy type field through legacy validation.
  3. SchemaRegistry
    • Owns registered schemas and base-to-derived relationships.
    • Loads plugin libraries before taking its registry mutex so static registration cannot deadlock.
    • Retains independent library handles while registered schemas may reference plugin code.

Usage Guide

Declaring Fields for Derived Types

To enable plugin info structure validation on a field, use acceptsDerivedTypes():

PropertyTreeBuilder()
.customType("constraint", "BaseConstraint")
.acceptsDerivedTypes() // Enable plugin info validation
.done()

acceptsDerivedTypes() applies to the current custom-type field and enables validation of the standard class + config plugin info structure. Call it after customType() and before done().

Plugin Container Helpers

The self-closing pluginContainer() and pluginContainerMap() helpers build the standard Tesseract plugin configuration shapes without manually repeating their schemas:

auto schema = PropertyTreeBuilder()
.attribute(property_attribute::TYPE, property_type::CONTAINER)
// { default: string, plugins: Map[String, FactoryBaseType] }
.pluginContainer("executors", "my::ExecutorFactory", "Executor")
// Map[String, { default: string, plugins: Map[String, FactoryBaseType] }]
.pluginContainerMap("kinematics", "my::KinematicsFactory", "Kinematics")
.build();

Both helpers arrange derived-type validation for plugin entries. They are self-closing, so no matching done() call is required. The plugins map inside a plugin container is required; represent an empty container as plugins: {}.

pluginContainer() and pluginContainerMap() record the boost_plugin_loader section used to discover implementations. This allows generic configuration tools to discover compatible aliases and retrieve their schemas without depending on the concrete plugin factory. The section is mandatory because every plugin must belong to an export section:

auto schema = PropertyTreeBuilder()
.attribute(property_attribute::TYPE, property_type::CONTAINER)
.attribute(property_attribute::CONFIG_KEY, "kinematic_plugins")
.pluginContainerMap("fwd_kin_plugins", "tesseract::kinematics::FwdKinFactory", "FwdKin")
.pluginContainerMap("inv_kin_plugins", "tesseract::kinematics::InvKinFactory", "InvKin")
.build();

For a direct PluginInfoContainer, such as a contact-manager configuration, use the corresponding overload:

auto schema = PropertyTreeBuilder()
.attribute(property_attribute::TYPE, property_type::CONTAINER)
.attribute(property_attribute::CONFIG_KEY, "contact_manager_plugins")
.pluginContainer("discrete_plugins", "tesseract::collision::DiscreteContactManagerFactory", "DiscColl")
.pluginContainer("continuous_plugins", "tesseract::collision::ContinuousContactManagerFactory", "ContColl")
.build();

These helpers attach plugin_base_type and plugin_section attributes to the container node. config_key identifies an optional outer YAML document key. Discovery input fields can be identified with plugin_discovery_role, whose standard values are search_paths and search_libraries.

Single Entry Example

For a single derived type instance:

Schema
auto schema = PropertyTreeBuilder()
.container("planning_config")
.customType("constraint", "BaseConstraint")
.acceptsDerivedTypes()
.doc("A single constraint instance")
.done()
.done()
.build();
YAML
planning_config:
constraint:
class: JointPositionConstraint
config:
joint: "shoulder_1"
position: 0.785
tolerance: 0.01

Array/Vector Example

For multiple instances of derived types:

Schema
auto schema = PropertyTreeBuilder()
.container("planning_problem")
.customType("constraints", "List[BaseConstraint]")
.acceptsDerivedTypes()
.doc("Array of constraints (each can be any derived type)")
.done()
.done()
.build();
YAML
planning_problem:
constraints:
- class: JointPositionConstraint
config:
joint: "joint_1"
position: 0.0
tolerance: 0.005
- class: CartesianVelocityConstraint
config:
frame: "tool0"
max_velocity: 1.5
- class: CollisionConstraint
config:
constraint_type: "USE_LIMITS"
safety_margin: 0.05

Map Example

For named instances of derived types:

Schema
auto schema = PropertyTreeBuilder()
.container("scenario")
.customType("constraints", "Map[String,BaseConstraint]")
.acceptsDerivedTypes()
.doc("Map of named constraints")
.done()
.done()
.build();
YAML
scenario:
constraints:
home_position:
class: JointPositionConstraint
config:
joint: "wrist_3"
position: 1.57
safety_limits:
class: CollisionConstraint
config:
constraint_type: "USE_LIMITS"
safety_margin: 0.05
smooth_motion:
class: CartesianVelocityConstraint
config:
frame: "workspace_center"
max_velocity: 2.0

Registration

Before using derived types, register the type relationships:

PropertyTree collisionConstraintSchema()
{
return PropertyTreeBuilder()
.attribute(property_attribute::TYPE, property_type::CONTAINER)
.float64("weight").defaultVal(1.0).done()
.string("constraint_type").required().enumValues({"USE_LIMITS", "PENALTY"}).done()
.float64("safety_margin").defaultVal(0.05).done()
.build();
}
// Register the schema-producing functions defined here and in the quick start.
TESSERACT_SCHEMA_REGISTER(BaseConstraint, baseConstraintSchema);
TESSERACT_SCHEMA_REGISTER(JointPositionConstraint, jointPositionConstraintSchema);
TESSERACT_SCHEMA_REGISTER(CollisionConstraint, collisionConstraintSchema);
// Register the compatibility relationships.
TESSERACT_SCHEMA_REGISTER_DERIVED_TYPE(BaseConstraint, JointPositionConstraint);
TESSERACT_SCHEMA_REGISTER_DERIVED_TYPE(BaseConstraint, CollisionConstraint);

Registration macros belong at file scope. Do not place them in headers included by multiple translation units. Applications may instead call SchemaRegistry::registerSchema() and SchemaRegistry::registerDerivedType() directly when explicit runtime registration is preferable.

Validation Process

When validating a property with acceptsDerivedTypes() enabled:

  1. Extract class field: Required "class" field must be present
  2. Verify inheritance: Check if the class name is registered as a valid derived type
  3. Validate config: Validate the "config" field against that type's schema
  4. Collect errors: All validation errors are collected with proper path context

Error Messages

Error messages are hierarchical and contextual:

// Missing required 'class' field
"planning_config.constraint: plugin info structure missing required 'class' field"
// Invalid derived type
"planning_config.constraint.class: type 'UnknownConstraint' does not derive from 'BaseConstraint'"
// Configuration validation error
"planning_problem.constraints[1].config.max_velocity: value 1.2 is less than minimum 1.5"
// Map value error
"scenario.constraints[safety_limits].config.constraint_type: not in enum values"

Supported Container Types

Derived types are directly supported in the following forms:

Container Type Type Notation YAML Pattern
Single Entry BaseType { class: ..., config: ... }
Dynamic Array List[BaseType] [ { class: ..., config: ... }, ... ]
Fixed Array List[BaseType,N] Fixed-size array of plugin infos
Map Map[String,BaseType] { key1: { class: ..., config: ... }, ... }

Nested type expressions such as Map[String,List[BaseType]] are not parsed. Model nested collections with an explicit intermediate registered schema instead. pluginContainerMap() uses this pattern internally.

Backward Compatibility

The legacy "type" field approach remains supported:

Legacy Approach (still works)
constraint:
type: JointPositionConstraint
joint: "joint_1"
position: 0.785

This works when acceptsDerivedTypes() is not set. However, the plugin info structure is now the recommended approach because it:

  • Clearly separates type identification from configuration
  • Aligns with Tesseract's standard PluginInfo pattern
  • Supports better composition and nesting
  • Provides clearer YAML organization

Derived Types vs. OneOf

Both features enable polymorphic-like configuration but serve different purposes:

Feature Derived Types OneOf
Use Case Type inheritance hierarchies (heterogeneous) Mutually exclusive variants
Type Specification Explicit "class" field Implicit - determined by required fields present
Flexibility Each element can be different derived type All choices defined at schema time
Extensibility Very easy - register new types at runtime Requires schema modification
Structure Plugin info (class + config) Multiple sibling containers
Array/Map Elements Can mix different types Must be single type variant
Example Use Case Constraints (different at each position) Shape types (Circle vs. Rectangle)

When to use Derived Types

  • Type hierarchies with inheritance relationships
  • Plugin architectures where new types are registered dynamically
  • Collections that mix different derived types in same array/map
  • Standard type selection via "class" field (plugin info pattern)

When to use OneOf

  • Mutually exclusive configuration options
  • Variant types without inheritance notion
  • Configuration structure determines type

Complete Working Example

See the comprehensive example at:

  • Doxygen Source File: common/examples/property_tree_derived_types_example.cpp

The example demonstrates:

  • Single constraint entry
  • Array of mixed constraint types
  • Named constraints in a map
  • Error handling and validation
  • Registration patterns

Implementation Details

The implementation is located in:

Best Practices

  1. Use Plugin Info Structure: Always use class + config pattern with acceptsDerivedTypes()
  2. Register Early: Register derived types at module initialization
  3. Clear Class Names: Use fully qualified type names (e.g., "tesseract::planning::JointPositionConstraint")
  4. Validate Config: Always include required fields in derived type schemas
  5. Error Handling: Check validation results before accessing configuration
  6. Documentation: Add description metadata to explain which types are valid
  7. Consistency: Use nested config for all type-specific parameters
  8. Testing: Test with all registered derived types
  9. Versioning: Consider schema version in derived type registration for compatibility

Quick Reference Guide

Plugin Info Structure

Every derived type instance has two fields:

field:
class: TypeIdentifier # Type name (required)
config: # Type-specific parameters (optional)
param1: value1
param2: value2

Common Error Messages

Error Meaning Solution
plugin info structure missing required class field No class identifier Add class: TypeName
type X does not derive from BaseType Invalid type name Register the type or use a valid derived type
value cannot be empty (in config) Config validation failed Check schema requirements for the type
expected a plugin info structure Wrong YAML structure Use { class: ..., config: ... } format

Validation Flow

  1. Check "class" field exists (required)
  2. Verify class is registered as derived from base type
  3. Validate "config" against that type's schema
  4. Collect all errors with path context

Schema Attributes

Key attributes for derived type fields:

.customType("field_name", "BaseType") // Mark as custom type
.acceptsDerivedTypes() // Enable plugin info validation [REQUIRED]
.doc("Description") // Document the field
.required() // Make the field mandatory
.done()

The .acceptsDerivedTypes() attribute is what enables plugin info structure validation.

Container Types

Container Type Notation YAML Example
Single BaseType { class: Type, config: {...} }
Array List[BaseType] [ { class: Type, config: {...} }, ... ]
Fixed Array List[BaseType,N] Fixed-size array of plugin infos
Map Map[String,BaseType] { key1: { class: Type, config: {...} }, ... }

Implementation Patterns

Pattern 1: Extensible Plugin System

.customType("solver", "BaseSolver")
.acceptsDerivedTypes()
.doc("Any registered solver implementation can be used here")

Pattern 2: Multiple Configurations

.customType("constraints", "List[BaseConstraint]")
.acceptsDerivedTypes()
.doc("List of constraints - can mix different types")

Pattern 3: Named Component Map

.customType("plugins", "Map[String,BasePlugin]")
.acceptsDerivedTypes()
.doc("Named plugin instances")

Legacy "Type" Field

When acceptsDerivedTypes() is not set, the old "type" field approach still works:

constraint:
type: JointPositionConstraint
joint: "joint_1"
position: 0.785

However, use plugin info structure (class + config) for new code.

Migration Guide

To migrate from the legacy "type" field approach:

Before (legacy, still works)
constraints:
- type: JointPositionConstraint
joint: "joint_1"
position: 0.0
.customType("constraints", "List[BaseConstraint]")
// No acceptsDerivedTypes() call
After (recommended)
constraints:
- class: JointPositionConstraint
config:
joint: "joint_1"
position: 0.0
.customType("constraints", "List[BaseConstraint]")
.acceptsDerivedTypes() // Add this line
Changes Required
  1. Add .acceptsDerivedTypes() to the schema definition
  2. Restructure YAML: wrap config in "config" field
  3. Change "type" field to "class" field in YAML files
  4. No C++ code changes to validation logic - automatic detection of new structure