1. Purpose

Canon distinguishes between two types of differences when comparing documents:

  • Normative diffs: Differences that affect equivalence based on your match options

  • Informative diffs: Differences that don’t affect equivalence (semantically equivalent per match options)

This allows you to focus on differences that matter while optionally viewing formatting-only changes.

2. Classification System

2.1. How Classification Works

Each difference is classified based on the match dimension’s behavior:

Match Behavior Classification Meaning

:strict

Normative

Must match exactly - any difference fails equivalence

:normalize

Normative

Must match after normalization - remaining differences fail equivalence

:ignore

Informative

Difference exists but doesn’t affect equivalence

Example 1. Example: Attribute order classification

Given:

<div class="TOC" id="_">   <!-- Document 1 -->
<div id="_" class="TOC">   <!-- Document 2 -->

Classification depends on attribute_order setting:

  • attribute_order: :strictNORMATIVE (order matters)

    • Shown in red/green

    • Causes equivalence to fail

  • attribute_order: :ignoreINFORMATIVE (order doesn’t matter)

    • Shown in cyan with ~~ markers

    • Documents are still equivalent

3. Architecture

┌──────────────────────────────────────────────────────────────────┐
│ COMPARISON LAYER                                                  │
│                                                                   │
│ Tree Comparator creates DiffNodes for each difference            │
│  ├─ References to nodes in both trees                            │
│  ├─ Dimension (:text_content, :attribute_order, etc.)            │
│  ├─ Reason code (UNEQUAL_TEXT_CONTENTS, etc.)                    │
│  └─ Normative flag (nil, to be classified)                       │
└───────────────────────────────────┬───────────────────────────────┘
                                    ↓
┌──────────────────────────────────────────────────────────────────┐
│ CLASSIFICATION LAYER                                              │
│                                                                   │
│ DiffClassifier examines each DiffNode:                           │
│                                                                   │
│ 1. Serialization-level formatting (XmlSerializationFormatter)    │
│    → XML syntax differences: <tag/> vs <tag></tag>               │
│    → ALWAYS formatting-only (non-normative)                      │
│                                                                   │
│ 2. Content-level formatting (text_content: :normalize)           │
│    → Whitespace differences in content                           │
│    → Formatting-only when normalized content matches             │
│                                                                   │
│ 3. CompareProfile policy (normative vs informative)              │
│    → behavior == :ignore → INFORMATIVE                           │
│    → behavior == :strict → NORMATIVE                             │
│    → behavior == :normalize → Check content normalization        │
│                                                                   │
│ Sets diff_node.formatting = true/false                           │
│ Sets diff_node.normative = true/false                            │
└───────────────────────────────────┬───────────────────────────────┘
                                    ↓
┌──────────────────────────────────────────────────────────────────┐
│ RENDERING LAYER                                                   │
│                                                                   │
│ Normative diffs:      Informative diffs:                         │
│  18| - | <div ...>     18| ~~ | <div ...>                        │
│  18| + | <div ...>     18| ~~ | <div ...>                        │
│      ▲                      ▲                                     │
│  Red/Green markers      Cyan markers                              │
└──────────────────────────────────────────────────────────────────┘

3.1. Three-Level Classification System

Canon distinguishes between three distinct kinds of differences:

| Kind | formatting: | normative: | Meaning | Examples | |------|---------------|--------------|---------|----------| | Serialization formatting | true | false | XML syntax differences | <tag/> vs <tag></tag> | | Content formatting | true | false | Whitespace in content | Hello world vs Hello world | | Informative | false | false | Tracked but doesn’t affect equivalence | Attribute order (when :ignore) | | Normative | false | true | Affects equivalence | Different words, missing elements |

Key distinction:

  • Serialization-level formatting: XML syntax differences that are ALWAYS non-normative regardless of match options, because they represent different valid serializations of the same semantic content. Detected by XmlSerializationFormatter.

  • Content-level formatting: Whitespace differences in document content. These are formatting-only (non-normative) when normalized content matches (using text_content: :normalize).

  • Informative: Differences tracked for reference but don’t affect equivalence (when behavior is :ignore).

  • Normative: Semantic content differences that affect equivalence (when behavior is :strict or when normalized content differs).

4. CompareProfile-Based Classification

4.1. Overview

Canon’s classification system uses CompareProfile to determine which differences are normative (affect equivalence) versus informative (do not affect equivalence).

The classification flow:

DiffNode → DiffClassifier → CompareProfile → normative?
                                   ↓
                           dimension + behavior → decision

4.2. Classification Hierarchy

Canon uses a multi-level hierarchy for classifying differences:

DiffNode → DiffClassifier → XmlSerializationFormatter → serialization formatting?
                                       ↓
                                  CompareProfile → normative dimension?
                                       ↓
                                  FormattingDetector → formatting-only?
                                       ↓
                                  Final classification

Classification priority (from highest to lowest specificity):

  1. Serialization-level formatting (highest priority)

    • XML syntax differences: <tag/> vs <tag></tag>

    • Detected by XmlSerializationFormatter

    • ALWAYS formatting: true, normative: false

    • Bypasses all other classification logic

  2. Content-level formatting

    • Whitespace differences in document content

    • Detected by FormattingDetector when text_content: :normalize

    • formatting: true, normative: false when normalized content matches

    • Respects element-level whitespace sensitivity

  3. Informative (based on :ignore behavior)

    • Tracked but doesn’t affect equivalence

    • formatting: false, normative: false

    • Example: Attribute order when attribute_order: :ignore

  4. Normative (based on :strict behavior or content mismatch)

    • Affects equivalence

    • formatting: false, normative: true

    • Example: Different words, missing elements

4.3. Format-Specific Policies

Each format can define its own policies through CompareProfile subclasses:

4.3.1. XML (Base CompareProfile)

Default policies: - Comments: normative (:strict) - Whitespace: normative (:strict) - All dimensions: strict by default

4.3.2. HTML (HtmlCompareProfile)

Default policies: - Comments: informative (:ignore) - presentational content - Whitespace: preserved in <pre>, <code>, <textarea>, <script>, <style> - Case sensitivity: HTML5 respects case, HTML4 is case-insensitive

4.3.3. Future formats

The architecture supports format-specific profiles for: - JSON: key order policies, type handling - YAML: comment handling, anchor/alias policies

4.4. Dimension Policy Examples

4.4.1. Structural Whitespace

  • :strict behavior → Normative

    • Whitespace must match exactly

    • Any whitespace difference causes non-equivalence

  • :normalize behavior → Informative (if formatting-only)

    • Whitespace differences detected as formatting-only when normalized content matches

    • Uses FormattingDetector to determine if difference is purely whitespace

  • :ignore behavior → Informative

    • Whitespace differences tracked but don’t affect equivalence

Example 2. Example: Structural whitespace with normalize
xml1 = '<root><p>Hello world</p></root>'
xml2 = '<root><p>Hello\nworld</p></root>'

# Normalize mode: formatting-only
result = Canon::Comparison.equivalent?(
  xml1, xml2,
  match: { text_content: :normalize, structural_whitespace: :normalize }
)
# => true (line break is formatting-only)

# Strict mode: normative
result = Canon::Comparison.equivalent?(xml1, xml2)
# => false (line break is normative in strict mode)

4.4.2. Comments

  • :strict behavior → Normative

    • Comments must match exactly

    • Comment differences cause non-equivalence

  • :ignore behavior → Informative

    • Comment differences tracked but don’t affect equivalence

    • Default for HTML (comments are presentational)

4.4.3. Element Structure

element_structure is a derived dimension — produced by comparators when children differ structurally (insertions, deletions, name changes). It is not a user-configurable match dimension but follows the same normative rule as all general dimensions:

  • Default (no explicit behavior) → Normative

    • Structural differences (added/removed/renamed elements) affect equivalence

  • :ignore behavior → Informative

    • Structural differences tracked but don’t affect equivalence

    • Useful for content-only comparisons where wrapper elements don’t matter

4.4.4. Whitespace Adjacency

:whitespace_adjacency is a derived dimension — emitted when the alignment walk pairs a whitespace-only text node on one side against a content node on the other. The Reason line describes the whitespace’s direction relative to the partner content: before, after, or adjacent to.

  • Always normative — differences always affect equivalence

  • Not user-configurable — dimension is always tracked when the re-alignment walk encounters an asymmetric whitespace node

  • Report-only — does not change equivalence outcomes compared to pre-#137 behaviour; only changes the diff-report shape (see Whitespace adjacency for details)

Example 3. Example: Comment handling
xml1 = '<root><!-- v1 --><data>text</data></root>'
xml2 = '<root><!-- v2 --><data>text</data></root>'

# Ignore mode: informative
result = Canon::Comparison.equivalent?(
  xml1, xml2,
  match: { comments: :ignore }
)
# => true (comment difference is informative)

# Strict mode: normative
result = Canon::Comparison.equivalent?(
  xml1, xml2,
  match: { comments: :strict }
)
# => false (comment difference is normative)

4.4.5. Text Content

  • :strict behavior → Normative

    • Text must match exactly, including all whitespace

    • Any text difference causes non-equivalence

  • :normalize behavior → Normative (after normalization) or Informative (if formatting-only)

    • Whitespace is normalized (collapsed/trimmed) before comparison

    • If normalized texts match but originals differ, classified as formatting-only (informative)

    • This ensures that whitespace-only differences don’t affect equivalence

    • Element-level sensitivity is respected (e.g., <pre>, <code> preserve whitespace)

  • :ignore behavior → Informative

    • Text content differences tracked but don’t affect equivalence

Example 4. Example: Text content with normalize behavior
# Formatting-only difference - normalized texts match
xml1 = '<p>Hello  world</p>'
xml2 = '<p>Hello world</p>'

result = Canon::Comparison.equivalent?(
  xml1, xml2,
  match: { text_content: :normalize }
)
# => true (extra space is formatting-only, classified as informative)

# Shows as informative in verbose output
result.differences.first.normative?
# => false
result.differences.first.formatting?
# => true
# HTML defaults: <code> is whitespace-sensitive
html1 = '<code>  indented  </code><p>  text  </p>'
html2 = '<code>indented</code><p>text</p>'

# With <code> blacklisted from sensitive elements
Canon::Comparison.equivalent?(html1, html2,
  format: :html,
  match: {
    strip_whitespace_elements: [:code],
  }
)
# => true
# - <code> whitespace: formatting-only (informative)
# - <p> whitespace: formatting-only (informative)

# Without blacklisting (default HTML behavior)
Canon::Comparison.equivalent?(html1, html2, format: :html)
# => false
# - <code> whitespace: normative (sensitive element)
# - <p> whitespace: formatting-only (informative)

Per XML standards, <tag/> and <tag></tag> are semantically equivalent (both represent empty elements). Canon classifies differences in serialisation format as formatting-only (non-normative):

# Self-closing vs explicit closing - always equivalent
xml1 = '<svg><rect x="10" y="10"/></svg>'
xml2 = '<svg><rect x="10" y="10"></rect></svg>'

Canon::Comparison.equivalent?(xml1, xml2, format: :xml)
# => true

# Empty/whitespace-only text nodes from serialisation are formatting-only
result = Canon::Comparison.equivalent?(xml1, xml2, format: :xml, verbose: true)
result.differences.each do |diff|
  if diff.dimension == :text_content
    puts "Normative: #{diff.normative?}"  # => false
    puts "Formatting: #{diff.formatting?}"  # => true
  end
end

This applies regardless of text_content behavior setting, as these differences are purely serialisation format variations (similar to attribute order).

The key insight: empty or whitespace-only text nodes created by different serialisation styles (<tag/> vs <tag></tag>) are always classified as formatting-only, not normative.

=== FormattingDetector Integration

For dimensions that support it (:text_content, :structural_whitespace), FormattingDetector is consulted to determine if a difference is formatting-only:

  1. Check normative status based on CompareProfile policy

  2. If non-normative, check if formatting-only using FormattingDetector

  3. If formatting-only, mark with formatting: true

<!-- Extra spaces around tag delimiter -->
<div>content</div>     <!-- xml1 -->
<div  >content</div>    <!-- xml2 - space before > -->

With :normalize mode: - Normalized: <div>content</div> (both) - Classification: Formatting-only - Markers: [ and ] - Equivalent: true

=== Implementation Details

The classification system uses three main classes:

  • XmlSerializationFormatter - Detects XML serialization-level formatting differences

    • Self-closing vs explicit closing tags: <tag/> vs <tag></tag>

    • Always returns formatting: true, normative: false

    • These differences are ALWAYS non-normative regardless of match options

  • CompareProfile - Determines dimension behavior and policy

    • normative_dimension?(dimension) - Is this dimension normative?

    • affects_equivalence?(dimension) - Does this dimension affect equivalence?

    • supports_formatting_detection?(dimension) - Can this dimension have formatting-only diffs?

    • Normative rules: structural_whitespace requires :strict for normative status; all other dimensions are normative unless behavior is :ignore

  • DiffClassifier - Orchestrates classification using the above

    • First checks XmlSerializationFormatter for serialization formatting

    • Then handles content-level formatting (text_content: :normalize)

    • Finally applies CompareProfile policy for normative vs informative

def classify(diff_node)
  # FIRST: Check for XML serialization-level formatting differences
  # These are ALWAYS non-normative (formatting-only) regardless of match options
  if XmlSerializationFormatter.serialization_formatting?(diff_node)
    diff_node.formatting = true
    diff_node.normative = false
    return diff_node
  end

  # SECOND: Handle content-level formatting for text_content with :normalize
  # Skipped for :preserve elements (<pre>, <code>, etc.) where whitespace is always normative
  if diff_node.dimension == :text_content &&
      profile.send(:behavior_for, :text_content) == :normalize &&
      !inside_preserve_element?(diff_node) &&
      formatting_only_diff?(diff_node)
    diff_node.formatting = true
    diff_node.normative = false
    return diff_node
  end

  # THIRD: Apply CompareProfile policy
  is_normative = profile.normative_dimension?(diff_node.dimension)

  # FOURTH: Check FormattingDetector for non-normative dimensions
  if !is_normative && profile.supports_formatting_detection?(diff_node.dimension)
    if formatting_only_diff?(diff_node)
      diff_node.formatting = true
      diff_node.normative = false
      return diff_node
    end
  end

  # FIFTH: Apply normative determination
  diff_node.normative = is_normative
  diff_node
end

The key distinction for text_content: :normalize:

  • Formatting-only detection: Uses normalized_equivalent? method to compare normalized texts

  • Element sensitivity: Respects element-level whitespace sensitivity (<pre>, <code>, etc.)

  • Result: Whitespace-only differences are classified as informative (non-normative) when using :normalize

== Visual Indicators

=== Normative Diffs

Colors: Red (deletions) and Green (additions)

Symbols: * - for deletions * + for additions * ~ for changes

  10|  - | <title>Old Title</title>
     | 11+ | <title>New Title</title>

Red and green indicate this difference causes files to be non-equivalent.

=== Informative Diffs

Color: Cyan

Symbol: ~~ (double tilde)

  10| ~~ | <div class="TOC" id="_">
     | ~~ | <div id="_" class="TOC">

Cyan color indicates this difference is informational only - files are still equivalent.

== Controlling Diff Display

Use the show_diffs option to filter which types of diffs are shown:

Option What’s Shown

:all (default)

Both normative and informative diffs

:normative

Only normative diffs (differences that matter)

:informative

Only informative diffs (for reference)

=== CLI

# Show only normative diffs
canon diff file1.xml file2.xml --show-diffs normative

# Show only informative diffs
canon diff file1.xml file2.xml --show-diffs informative

# Show all diffs (default)
canon diff file1.xml file2.xml --show-diffs all

=== Ruby API

# Show only normative diffs
Canon.compare(file1, file2,
  format: :xml,
  show_diffs: :normative
)

# Show all diffs
Canon.compare(file1, file2,
  format: :xml,
  show_diffs: :all
)

=== RSpec

# Configure globally
RSpec.configure do |config|
  config.canon.xml.diff.show_diffs = :normative
end

# Or per-test
expect(actual).to match_xml(expected).with_options(
  show_diffs: :normative
)

== Common Scenarios

=== Whitespace Differences

  5| ~~ | <p>Hello  world</p>    # Extra space (informative)
    | ~~ | <p>Hello world</p>

The difference is shown in cyan because extra whitespace is ignored.

  5|  - | <p>Hello  world</p>    # Extra space (normative)
    |  6+ | <p>Hello world</p>

The difference is shown in red/green because whitespace must match exactly.

=== Attribute Order

 10| ~~ | <div class="x" id="y">    # Informative
    | ~~ | <div id="y" class="x">
 10|  - | <div class="x" id="y">    # Normative
    | 11+ | <div id="y" class="x">

=== Comments

  3| ~~ | <!-- Old comment -->      # Informative
    | ~~ | <!-- New comment -->
  3|  - | <!-- Old comment -->      # Normative
    |  4+ | <!-- New comment -->

== Implementation

=== DiffClassifier

The [DiffClassifier](../../lib/canon/diff/diff_classifier.rb) class handles classification:

class DiffClassifier
  def initialize(match_options)
    @match_options = match_options
  end

  def classify(diff_node)
    behavior = @match_options.behavior_for(diff_node.dimension)
    diff_node.normative = (behavior != :ignore)
    diff_node
  end
end

=== Match Options Integration

Classification uses the resolved match options:

# Match options define behavior for each dimension
match_options = ResolvedMatchOptions.new(
  text_content: :normalize,        # Normative after normalization
  structural_whitespace: :ignore,  # Informative
  attribute_order: :ignore,        # Informative
  comments: :strict                # Normative
)

# Classifier uses these to determine normative/informative
classifier = DiffClassifier.new(match_options)

== Use Cases

=== Focus on Real Differences

Use show_diffs: :normative to hide formatting changes:

canon diff spec1.xml spec2.xml \
  --match-profile spec_friendly \
  --show-diffs normative

This shows only semantic differences, hiding attribute order, whitespace, and comments.

=== Review All Changes

Use show_diffs: :all during code review to see everything:

canon diff old.xml new.xml --show-diffs all

This shows both semantic and formatting changes for complete visibility.

=== Understand Match Configuration

Use show_diffs: :informative to see what’s being ignored:

canon diff file1.xml file2.xml \
  --match-profile strict \
  --show-diffs informative

This helps verify your match configuration is working as expected.

== See Also