Update specificity rule matching for annotations

Change-Id: Ifa7ec5eef3583cb196f4aa1ca0cfcd65790de226
diff --git a/ast/ast.go b/ast/ast.go
index b944824..df21ba9 100644
--- a/ast/ast.go
+++ b/ast/ast.go
@@ -487,11 +487,11 @@
 	SetRewrites([]Rewrite)
 }
 
-func (t *Term) GetRewrites() []Rewrite      { return t.Rewrites }
-func (t *Term) SetRewrites(r []Rewrite)      { t.Rewrites = r }
-func (tg *TermGroup) GetRewrites() []Rewrite { return tg.Rewrites }
+func (t *Term) GetRewrites() []Rewrite        { return t.Rewrites }
+func (t *Term) SetRewrites(r []Rewrite)       { t.Rewrites = r }
+func (tg *TermGroup) GetRewrites() []Rewrite  { return tg.Rewrites }
 func (tg *TermGroup) SetRewrites(r []Rewrite) { tg.Rewrites = r }
-func (t *Token) GetRewrites() []Rewrite      { return t.Rewrites }
+func (t *Token) GetRewrites() []Rewrite       { return t.Rewrites }
 func (t *Token) SetRewrites(r []Rewrite)      { t.Rewrites = r }
 
 // AppendRewrite appends a rewrite to any Rewriteable node.
@@ -502,6 +502,36 @@
 	}
 }
 
+// Specificity returns the specificity score of an AST node.
+// Specificity is the count of AND-connected leaf constraints:
+//   - Term -> 1
+//   - TermGroup(AND) -> sum of Specificity of all operands
+//   - TermGroup(OR) -> 0 (alternatives, not additional constraints)
+//   - Token -> Specificity(Wrap)
+//   - CatchallNode / nil -> 0
+func Specificity(node Node) int {
+	if node == nil {
+		return 0
+	}
+	switch n := node.(type) {
+	case *Term:
+		return 1
+	case *TermGroup:
+		if n.Relation == AndRelation {
+			total := 0
+			for _, op := range n.Operands {
+				total += Specificity(op)
+			}
+			return total
+		}
+		return 0
+	case *Token:
+		return Specificity(n.Wrap)
+	default:
+		return 0
+	}
+}
+
 // StripRewrites recursively removes all rewrites from an AST tree.
 func StripRewrites(node Node) {
 	if node == nil {
diff --git a/ast/ast_test.go b/ast/ast_test.go
index 3965f1d..4d8b97a 100644
--- a/ast/ast_test.go
+++ b/ast/ast_test.go
@@ -1133,6 +1133,107 @@
 	}
 }
 
+func TestSpecificity(t *testing.T) {
+	tests := []struct {
+		name     string
+		node     Node
+		expected int
+	}{
+		{
+			name:     "Term returns 1",
+			node:     &Term{Key: "ADJ", Match: MatchEqual},
+			expected: 1,
+		},
+		{
+			name: "TermGroup(AND) with 2 terms returns 2",
+			node: &TermGroup{
+				Relation: AndRelation,
+				Operands: []Node{
+					&Term{Key: "ADJ", Match: MatchEqual},
+					&Term{Key: "Variant", Value: "Short", Match: MatchEqual},
+				},
+			},
+			expected: 2,
+		},
+		{
+			name: "TermGroup(AND) with 3 terms returns 3",
+			node: &TermGroup{
+				Relation: AndRelation,
+				Operands: []Node{
+					&Term{Key: "VERB", Match: MatchEqual},
+					&Term{Key: "Mood", Value: "Ind", Match: MatchEqual},
+					&Term{Key: "VerbForm", Value: "Fin", Match: MatchEqual},
+				},
+			},
+			expected: 3,
+		},
+		{
+			name: "TermGroup(OR) with 2 terms returns 0",
+			node: &TermGroup{
+				Relation: OrRelation,
+				Operands: []Node{
+					&Term{Key: "ADJA", Match: MatchEqual},
+					&Term{Key: "ADJD", Match: MatchEqual},
+				},
+			},
+			expected: 0,
+		},
+		{
+			name: "Nested AND containing OR returns count of AND terms only",
+			node: &TermGroup{
+				Relation: AndRelation,
+				Operands: []Node{
+					&Term{Key: "DET", Match: MatchEqual},
+					&TermGroup{
+						Relation: OrRelation,
+						Operands: []Node{
+							&Term{Key: "PronType", Value: "Ind", Match: MatchEqual},
+							&Term{Key: "PronType", Value: "Neg", Match: MatchEqual},
+						},
+					},
+				},
+			},
+			expected: 1,
+		},
+		{
+			name: "Token wrapping TermGroup(AND) with 2 terms returns 2",
+			node: &Token{
+				Wrap: &TermGroup{
+					Relation: AndRelation,
+					Operands: []Node{
+						&Term{Key: "DET", Match: MatchEqual},
+						&Term{Key: "PronType", Value: "Art", Match: MatchEqual},
+					},
+				},
+			},
+			expected: 2,
+		},
+		{
+			name:     "nil node returns 0",
+			node:     nil,
+			expected: 0,
+		},
+		{
+			name:     "CatchallNode returns 0",
+			node:     &CatchallNode{NodeType: "koral:group"},
+			expected: 0,
+		},
+		{
+			name: "Token wrapping a simple Term returns 1",
+			node: &Token{
+				Wrap: &Term{Key: "NOUN", Match: MatchEqual},
+			},
+			expected: 1,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.expected, Specificity(tt.node))
+		})
+	}
+}
+
 func TestRestrictToObligatoryDoesNotModifyOriginal(t *testing.T) {
 	// Test that the original node is not modified
 	original := &TermGroup{