feat(rete): add negation nodes (NOT) (P2.1)
This commit is contained in:
parent
0401295bbc
commit
1731c43eb2
3 changed files with 486 additions and 0 deletions
|
|
@ -20,6 +20,8 @@ export { Token, BetaMemory, BetaMemoryNode } from "./beta.js";
|
|||
export type { JoinTest } from "./join.js";
|
||||
export { JoinNode } from "./join.js";
|
||||
|
||||
export { NegationNode, UnsafeNegationError } from "./negation.js";
|
||||
|
||||
export type { VariableDescriptor, RuleCondition, RuleDefinition, DefineRuleOpts } from "./builder.js";
|
||||
export { v, defineRule } from "./builder.js";
|
||||
export type { HandlerFn, PredicateFn } from "./registry.js";
|
||||
|
|
|
|||
235
packages/rete/src/negation.test.ts
Normal file
235
packages/rete/src/negation.test.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { NegationNode, UnsafeNegationError } from "./negation.js";
|
||||
import { AlphaMemory } from "./alpha.js";
|
||||
import { Token, BetaMemory } from "./beta.js";
|
||||
import type { JoinTest } from "./join.js";
|
||||
import type { EntityId } from "./schema.js";
|
||||
import type { AttrKey, FactValue } from "./wm.js";
|
||||
|
||||
const mkId = (n: number) => n as EntityId;
|
||||
const mkFact = (id: number, attr: string, value: unknown) => ({
|
||||
id: mkId(id),
|
||||
attr: attr as AttrKey,
|
||||
value: value as FactValue,
|
||||
});
|
||||
|
||||
describe("NegationNode", () => {
|
||||
it("passes token when negated pattern has NO matching facts", () => {
|
||||
const rightMem = new AlphaMemory();
|
||||
const outMem = new BetaMemory();
|
||||
const negation = new NegationNode(rightMem, []);
|
||||
negation.addDownstreamActivate((t) => outMem.leftActivate(t));
|
||||
|
||||
const token = new Token(null, mkFact(1, "X", 1), { x: 1 });
|
||||
negation.leftActivate(token);
|
||||
|
||||
expect(outMem.tokens).toHaveLength(1);
|
||||
expect(outMem.tokens[0]).toBe(token);
|
||||
});
|
||||
|
||||
it("blocks token when negated pattern HAS a matching fact", () => {
|
||||
const rightMem = new AlphaMemory();
|
||||
const outMem = new BetaMemory();
|
||||
const negation = new NegationNode(rightMem, []);
|
||||
negation.addDownstreamActivate((t) => outMem.leftActivate(t));
|
||||
|
||||
rightMem.addFact(mkId(99), "Dead" as AttrKey, true as FactValue);
|
||||
|
||||
const token = new Token(null, mkFact(1, "X", 1), { x: 1 });
|
||||
negation.leftActivate(token);
|
||||
|
||||
expect(outMem.tokens).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("right-activate: blocks a previously-passing token", () => {
|
||||
const rightMem = new AlphaMemory();
|
||||
const outMem = new BetaMemory();
|
||||
const negation = new NegationNode(rightMem, []);
|
||||
negation.addDownstreamActivate((t) => outMem.leftActivate(t));
|
||||
negation.addDownstreamDeactivate((t) => outMem.leftDeactivate(t));
|
||||
|
||||
const token = new Token(null, mkFact(1, "X", 1), { x: 1 });
|
||||
negation.leftActivate(token);
|
||||
expect(outMem.tokens).toHaveLength(1);
|
||||
|
||||
negation.rightActivate(mkId(99), "Dead" as AttrKey, true as FactValue);
|
||||
expect(outMem.tokens).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("right-deactivate: unblocks a previously-blocked token", () => {
|
||||
const rightMem = new AlphaMemory();
|
||||
const outMem = new BetaMemory();
|
||||
const negation = new NegationNode(rightMem, []);
|
||||
negation.addDownstreamActivate((t) => outMem.leftActivate(t));
|
||||
negation.addDownstreamDeactivate((t) => outMem.leftDeactivate(t));
|
||||
|
||||
rightMem.addFact(mkId(99), "Dead" as AttrKey, true as FactValue);
|
||||
const token = new Token(null, mkFact(1, "X", 1), { x: 1 });
|
||||
negation.leftActivate(token);
|
||||
expect(outMem.tokens).toHaveLength(0);
|
||||
|
||||
negation.rightDeactivate(mkId(99), "Dead" as AttrKey, true as FactValue);
|
||||
expect(outMem.tokens).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("multiple blocking facts: token stays blocked until ALL are retracted", () => {
|
||||
const rightMem = new AlphaMemory();
|
||||
const outMem = new BetaMemory();
|
||||
const negation = new NegationNode(rightMem, []);
|
||||
negation.addDownstreamActivate((t) => outMem.leftActivate(t));
|
||||
negation.addDownstreamDeactivate((t) => outMem.leftDeactivate(t));
|
||||
|
||||
rightMem.addFact(mkId(1), "Dead" as AttrKey, true as FactValue);
|
||||
rightMem.addFact(mkId(2), "Dead" as AttrKey, true as FactValue);
|
||||
|
||||
const token = new Token(null, mkFact(3, "X", 1), {});
|
||||
negation.leftActivate(token);
|
||||
expect(outMem.tokens).toHaveLength(0);
|
||||
|
||||
negation.rightDeactivate(mkId(1), "Dead" as AttrKey, true as FactValue);
|
||||
expect(outMem.tokens).toHaveLength(0);
|
||||
|
||||
negation.rightDeactivate(mkId(2), "Dead" as AttrKey, true as FactValue);
|
||||
expect(outMem.tokens).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("left-deactivate: removes a passing token from downstream", () => {
|
||||
const rightMem = new AlphaMemory();
|
||||
const outMem = new BetaMemory();
|
||||
const negation = new NegationNode(rightMem, []);
|
||||
negation.addDownstreamActivate((t) => outMem.leftActivate(t));
|
||||
negation.addDownstreamDeactivate((t) => outMem.leftDeactivate(t));
|
||||
|
||||
const token = new Token(null, mkFact(1, "X", 1), {});
|
||||
negation.leftActivate(token);
|
||||
expect(outMem.tokens).toHaveLength(1);
|
||||
|
||||
negation.leftDeactivate(token);
|
||||
expect(outMem.tokens).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("left-deactivate: does NOT emit downstream-deactivate when token was blocked", () => {
|
||||
const rightMem = new AlphaMemory();
|
||||
const outMem = new BetaMemory();
|
||||
const negation = new NegationNode(rightMem, []);
|
||||
let deactivateCalls = 0;
|
||||
negation.addDownstreamActivate((t) => outMem.leftActivate(t));
|
||||
negation.addDownstreamDeactivate(() => {
|
||||
deactivateCalls++;
|
||||
});
|
||||
|
||||
rightMem.addFact(mkId(99), "Dead" as AttrKey, true as FactValue);
|
||||
const token = new Token(null, mkFact(1, "X", 1), {});
|
||||
negation.leftActivate(token);
|
||||
expect(outMem.tokens).toHaveLength(0);
|
||||
|
||||
negation.leftDeactivate(token);
|
||||
expect(deactivateCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("idEquality join test: only blocking facts matching token binding count", () => {
|
||||
const rightMem = new AlphaMemory();
|
||||
const outMem = new BetaMemory();
|
||||
const tests: JoinTest[] = [{ type: "idEquality", leftVar: "target" }];
|
||||
const negation = new NegationNode(rightMem, tests);
|
||||
negation.addDownstreamActivate((t) => outMem.leftActivate(t));
|
||||
negation.addDownstreamDeactivate((t) => outMem.leftDeactivate(t));
|
||||
|
||||
// Fact on entity 42 is unrelated to our token's target=7
|
||||
rightMem.addFact(mkId(42), "Dead" as AttrKey, true as FactValue);
|
||||
|
||||
const token = new Token(null, mkFact(7, "X", 1), { target: mkId(7) });
|
||||
negation.leftActivate(token);
|
||||
// Unrelated fact does not block
|
||||
expect(outMem.tokens).toHaveLength(1);
|
||||
|
||||
// Now a matching fact arrives
|
||||
negation.rightActivate(mkId(7), "Dead" as AttrKey, true as FactValue);
|
||||
expect(outMem.tokens).toHaveLength(0);
|
||||
|
||||
// Unrelated fact retracted — still blocked (didn't count)
|
||||
negation.rightDeactivate(mkId(42), "Dead" as AttrKey, true as FactValue);
|
||||
expect(outMem.tokens).toHaveLength(0);
|
||||
|
||||
// Matching fact retracted — unblocked
|
||||
negation.rightDeactivate(mkId(7), "Dead" as AttrKey, true as FactValue);
|
||||
expect(outMem.tokens).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("valueEquality join test: right value must equal bound variable", () => {
|
||||
const rightMem = new AlphaMemory();
|
||||
const outMem = new BetaMemory();
|
||||
const tests: JoinTest[] = [{ type: "valueEquality", leftVar: "needle" }];
|
||||
const negation = new NegationNode(rightMem, tests);
|
||||
negation.addDownstreamActivate((t) => outMem.leftActivate(t));
|
||||
negation.addDownstreamDeactivate((t) => outMem.leftDeactivate(t));
|
||||
|
||||
rightMem.addFact(mkId(1), "Tag" as AttrKey, "red" as FactValue);
|
||||
|
||||
// Token looking for "blue" — not blocked
|
||||
const tokenBlue = new Token(null, mkFact(1, "X", 1), { needle: "blue" });
|
||||
negation.leftActivate(tokenBlue);
|
||||
expect(outMem.tokens).toHaveLength(1);
|
||||
|
||||
// Token looking for "red" — blocked by existing fact
|
||||
const tokenRed = new Token(null, mkFact(2, "X", 1), { needle: "red" });
|
||||
negation.leftActivate(tokenRed);
|
||||
expect(outMem.tokens).toHaveLength(1); // only blue passed
|
||||
});
|
||||
|
||||
it("idempotent right-deactivate on unknown fact does not underflow", () => {
|
||||
const rightMem = new AlphaMemory();
|
||||
const outMem = new BetaMemory();
|
||||
const negation = new NegationNode(rightMem, []);
|
||||
negation.addDownstreamActivate((t) => outMem.leftActivate(t));
|
||||
negation.addDownstreamDeactivate((t) => outMem.leftDeactivate(t));
|
||||
|
||||
const token = new Token(null, mkFact(1, "X", 1), {});
|
||||
negation.leftActivate(token);
|
||||
expect(outMem.tokens).toHaveLength(1);
|
||||
|
||||
// Retract a fact that was never added — count should clamp at 0
|
||||
negation.rightDeactivate(
|
||||
mkId(99),
|
||||
"Dead" as AttrKey,
|
||||
true as FactValue,
|
||||
);
|
||||
expect(outMem.tokens).toHaveLength(1);
|
||||
|
||||
// Real blocking activation still works
|
||||
negation.rightActivate(mkId(99), "Dead" as AttrKey, true as FactValue);
|
||||
expect(outMem.tokens).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("multiple left tokens: each tracked independently", () => {
|
||||
const rightMem = new AlphaMemory();
|
||||
const outMem = new BetaMemory();
|
||||
const tests: JoinTest[] = [{ type: "idEquality", leftVar: "tid" }];
|
||||
const negation = new NegationNode(rightMem, tests);
|
||||
negation.addDownstreamActivate((t) => outMem.leftActivate(t));
|
||||
negation.addDownstreamDeactivate((t) => outMem.leftDeactivate(t));
|
||||
|
||||
const t1 = new Token(null, mkFact(1, "X", 1), { tid: mkId(1) });
|
||||
const t2 = new Token(null, mkFact(2, "X", 1), { tid: mkId(2) });
|
||||
negation.leftActivate(t1);
|
||||
negation.leftActivate(t2);
|
||||
expect(outMem.tokens).toHaveLength(2);
|
||||
|
||||
// Fact blocks only t1
|
||||
negation.rightActivate(mkId(1), "Dead" as AttrKey, true as FactValue);
|
||||
expect(outMem.tokens).toHaveLength(1);
|
||||
expect(outMem.tokens[0]).toBe(t2);
|
||||
|
||||
// Retract it — t1 unblocks
|
||||
negation.rightDeactivate(mkId(1), "Dead" as AttrKey, true as FactValue);
|
||||
expect(outMem.tokens).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("UnsafeNegationError class exists and carries the unbound var name", () => {
|
||||
const err = new UnsafeNegationError("eid");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(UnsafeNegationError);
|
||||
expect(err.name).toBe("UnsafeNegationError");
|
||||
expect(err.message).toContain("eid");
|
||||
});
|
||||
});
|
||||
249
packages/rete/src/negation.ts
Normal file
249
packages/rete/src/negation.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
/**
|
||||
* NegationNode — NOT pattern, per Doorenbos 1995 §2.6.1.
|
||||
*
|
||||
* A negation node passes a left token downstream only when **zero** facts in
|
||||
* its right {@link AlphaMemory} satisfy the negated condition's join tests
|
||||
* against that token. Any matching fact inhibits the token.
|
||||
*
|
||||
* ### Count-based bookkeeping
|
||||
*
|
||||
* The node maintains a per-token block count: how many right facts currently
|
||||
* satisfy the join tests for that token. A token is downstream-active iff its
|
||||
* count is zero. This avoids scanning the whole right memory on every right-
|
||||
* side event — a change only needs to inspect left tokens once, adjust their
|
||||
* counts, and fire activate/deactivate transitions on the 0↔1 boundary.
|
||||
*
|
||||
* ### Activation flow
|
||||
*
|
||||
* - **leftActivate(token)**: count matching right facts once; if zero, emit
|
||||
* downstream activate immediately, else record the count and stay quiet.
|
||||
* - **leftDeactivate(token)**: forget the token; if it was currently passing,
|
||||
* fire downstream deactivate. No-op for blocked tokens — downstream never
|
||||
* saw them.
|
||||
* - **rightActivate(fact)**: for each left token whose join tests pass with
|
||||
* this fact, increment count. If the count transitions 0→1, fire downstream
|
||||
* deactivate (the token was passing and is now blocked).
|
||||
* - **rightDeactivate(fact)**: for each left token whose join tests pass with
|
||||
* this fact, decrement count (clamped at 0). If the count transitions 1→0,
|
||||
* fire downstream activate.
|
||||
*
|
||||
* ### Unsafe NOT
|
||||
*
|
||||
* A negated condition referencing a variable that is not bound in the left
|
||||
* token is "unsafe" (Doorenbos §2.6.1) — the match set is unbounded. We
|
||||
* expose {@link UnsafeNegationError} for the rule builder to throw when it
|
||||
* detects this statically. The node itself accepts any {@link JoinTest} list
|
||||
* at construction; detection lives at rule-build time where the bound-
|
||||
* variable set is known.
|
||||
*/
|
||||
import type { EntityId } from "./schema.js";
|
||||
import type { AttrKey, FactValue } from "./wm.js";
|
||||
import type { Token } from "./beta.js";
|
||||
import type { AlphaMemory } from "./alpha.js";
|
||||
import type { JoinTest } from "./join.js";
|
||||
|
||||
/**
|
||||
* Thrown by the rule builder when a negated condition references a variable
|
||||
* that is not bound by any earlier (positive) condition in the rule.
|
||||
*
|
||||
* The node itself does not throw this — it has no visibility into the full
|
||||
* rule's binding set. The error type lives here so the builder can report
|
||||
* it with a consistent name and callers can catch it specifically.
|
||||
*/
|
||||
export class UnsafeNegationError extends Error {
|
||||
constructor(unboundVar: string) {
|
||||
super(
|
||||
`UnsafeNegationError: variable "${unboundVar}" in NOT pattern is not bound by any preceding condition`,
|
||||
);
|
||||
this.name = "UnsafeNegationError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Downstream subscriber fired when a token begins passing (count hit 0). */
|
||||
type ActivateListener = (token: Token) => void;
|
||||
/** Downstream subscriber fired when a token stops passing (count left 0). */
|
||||
type DeactivateListener = (token: Token) => void;
|
||||
|
||||
/**
|
||||
* Beta-network NOT node.
|
||||
*
|
||||
* Left input: tokens from an upstream {@link BetaMemory} / {@link JoinNode}.
|
||||
* Right input: an {@link AlphaMemory} of facts that match the negated pattern's
|
||||
* constant tests. The node does not subscribe to the alpha memory itself —
|
||||
* the network wiring (typically the builder) calls {@link rightActivate} and
|
||||
* {@link rightDeactivate} when alpha-memory membership changes.
|
||||
*
|
||||
* Output: a subset of left tokens (never extended — negation contributes no
|
||||
* new bindings), emitted to registered downstream activate/deactivate
|
||||
* listeners in registration order.
|
||||
*/
|
||||
export class NegationNode {
|
||||
readonly #activateListeners: ActivateListener[] = [];
|
||||
readonly #deactivateListeners: DeactivateListener[] = [];
|
||||
|
||||
/**
|
||||
* Per-token block count: number of right facts currently satisfying the
|
||||
* join tests for that token. A token passes downstream iff its count is
|
||||
* zero. Absent = token unknown (never left-activated or already
|
||||
* left-deactivated).
|
||||
*/
|
||||
readonly #blockCount = new Map<Token, number>();
|
||||
|
||||
/**
|
||||
* Tokens that are currently emitted downstream (count === 0 and not yet
|
||||
* left-deactivated). Maintained in lock-step with {@link #blockCount} so
|
||||
* that we know whether a deactivate transition actually needs to fire.
|
||||
*/
|
||||
readonly #passingTokens = new Set<Token>();
|
||||
|
||||
/**
|
||||
* Every left token we've seen and not yet deactivated, in insertion order.
|
||||
* Right-side events iterate this to determine which counts to adjust.
|
||||
* Kept as an array (not a Set) so iteration order is deterministic per
|
||||
* SPEC.md §Iteration Order.
|
||||
*/
|
||||
readonly #leftTokens: Token[] = [];
|
||||
|
||||
readonly #rightMemory: AlphaMemory;
|
||||
readonly #tests: readonly JoinTest[];
|
||||
|
||||
/**
|
||||
* @param rightMemory Alpha memory supplying candidate blocking facts.
|
||||
* @param tests Equality tests that a fact must satisfy (against the
|
||||
* left token's bindings) to count as a blocker. An empty
|
||||
* list means any fact in `rightMemory` blocks.
|
||||
*/
|
||||
constructor(rightMemory: AlphaMemory, tests: readonly JoinTest[]) {
|
||||
this.#rightMemory = rightMemory;
|
||||
this.#tests = tests;
|
||||
}
|
||||
|
||||
/** Subscribe to downstream activations. Listeners fire in registration order. */
|
||||
addDownstreamActivate(listener: ActivateListener): void {
|
||||
this.#activateListeners.push(listener);
|
||||
}
|
||||
|
||||
/** Subscribe to downstream deactivations. Listeners fire in registration order. */
|
||||
addDownstreamDeactivate(listener: DeactivateListener): void {
|
||||
this.#deactivateListeners.push(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a new left token.
|
||||
*
|
||||
* Computes the initial block count against the current right memory. If
|
||||
* zero, the token is emitted downstream immediately; otherwise it is held
|
||||
* in internal state until enough blockers are retracted to drop the count
|
||||
* back to zero.
|
||||
*/
|
||||
leftActivate(token: Token): void {
|
||||
this.#leftTokens.push(token);
|
||||
const count = this.#countMatchingFacts(token);
|
||||
this.#blockCount.set(token, count);
|
||||
|
||||
if (count === 0) {
|
||||
this.#passingTokens.add(token);
|
||||
for (const l of this.#activateListeners) l(token);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw a left token.
|
||||
*
|
||||
* Only fires downstream deactivate listeners if the token was currently
|
||||
* passing — downstream never saw a blocked token, so retracting it would
|
||||
* be a spurious event.
|
||||
*/
|
||||
leftDeactivate(token: Token): void {
|
||||
const idx = this.#leftTokens.indexOf(token);
|
||||
if (idx !== -1) this.#leftTokens.splice(idx, 1);
|
||||
|
||||
const wasPassing = this.#passingTokens.delete(token);
|
||||
this.#blockCount.delete(token);
|
||||
|
||||
if (wasPassing) {
|
||||
for (const l of this.#deactivateListeners) l(token);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a new right-side fact.
|
||||
*
|
||||
* For each known left token whose join tests pass with this fact, increment
|
||||
* the block count. A 0→1 transition means the token just became blocked —
|
||||
* fire downstream deactivate.
|
||||
*/
|
||||
rightActivate(id: EntityId, _attr: AttrKey, value: FactValue): void {
|
||||
for (const token of this.#leftTokens) {
|
||||
if (!this.#factMatchesToken(token, id, value)) continue;
|
||||
const prev = this.#blockCount.get(token) ?? 0;
|
||||
this.#blockCount.set(token, prev + 1);
|
||||
|
||||
if (prev === 0 && this.#passingTokens.has(token)) {
|
||||
this.#passingTokens.delete(token);
|
||||
for (const l of this.#deactivateListeners) l(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a retracted right-side fact.
|
||||
*
|
||||
* For each known left token whose join tests pass with this fact, decrement
|
||||
* the block count (clamped at zero against spurious retracts). A 1→0
|
||||
* transition means the token is newly unblocked — fire downstream activate.
|
||||
*/
|
||||
rightDeactivate(id: EntityId, _attr: AttrKey, value: FactValue): void {
|
||||
for (const token of this.#leftTokens) {
|
||||
if (!this.#factMatchesToken(token, id, value)) continue;
|
||||
const prev = this.#blockCount.get(token) ?? 0;
|
||||
const next = prev > 0 ? prev - 1 : 0;
|
||||
this.#blockCount.set(token, next);
|
||||
|
||||
if (prev === 1 && next === 0 && !this.#passingTokens.has(token)) {
|
||||
this.#passingTokens.add(token);
|
||||
for (const l of this.#activateListeners) l(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count right facts that currently satisfy the join tests for `token`.
|
||||
*
|
||||
* Called only on {@link leftActivate} — incremental right-side events
|
||||
* maintain the count thereafter. O(|rightMemory| · |tests|) per token.
|
||||
*/
|
||||
#countMatchingFacts(token: Token): number {
|
||||
let count = 0;
|
||||
for (const fact of this.#rightMemory.facts) {
|
||||
if (this.#factMatchesToken(token, fact.id, fact.value)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a right fact `(rightId, rightValue)` satisfies every join
|
||||
* test against `token`.
|
||||
*
|
||||
* A missing `leftVar` binding fails the test — this mirrors {@link JoinNode}
|
||||
* and protects against builder bugs that reference an unbound variable
|
||||
* despite the {@link UnsafeNegationError} guard.
|
||||
*/
|
||||
#factMatchesToken(
|
||||
token: Token,
|
||||
rightId: EntityId,
|
||||
rightValue: FactValue,
|
||||
): boolean {
|
||||
for (const test of this.#tests) {
|
||||
const leftVal = token.bindings[test.leftVar];
|
||||
if (leftVal === undefined) return false;
|
||||
if (test.type === "idEquality") {
|
||||
if (leftVal !== rightId) return false;
|
||||
} else {
|
||||
// valueEquality
|
||||
if (leftVal !== rightValue) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue