Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions bin/single-node/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ http {
proxy_set_header X-Real-IP $remote_addr;
}

location /api/workflow-to-python {
proxy_pass http://workflow-compiling-service:9090;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}

location /api/dataset {
proxy_pass http://file-service:9092;
proxy_set_header Host $host;
Expand Down
5 changes: 5 additions & 0 deletions frontend/proxy.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@
"secure": false,
"changeOrigin": true
},
"/api/workflow-to-python": {
"target": "http://localhost:9090",
"secure": false,
"changeOrigin": true
},
"/api/dataset": {
"target": "http://localhost:9092",
"secure": false,
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/app/common/formly/formly-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { DatasetFileSelectorComponent } from "../../workspace/component/dataset-
import { CollabWrapperComponent } from "./collab-wrapper/collab-wrapper/collab-wrapper.component";
import { FormlyRepeatDndComponent } from "./repeat-dnd/repeat-dnd.component";
import { UiUdfParametersComponent } from "../../workspace/component/ui-udf-parameters/ui-udf-parameters.component";
import { ConstrainedValueComponent } from "../../workspace/component/constrained-value/constrained-value.component";
import { DatasetVersionSelectorComponent } from "../../workspace/component/dataset-version-selector/dataset-version-selector.component";
import { HuggingFaceImageUploadComponent } from "../../workspace/component/hugging-face-image-upload/hugging-face-image-upload.component";
import { HuggingFaceComponent } from "../../workspace/component/hugging-face/hugging-face.component";
Expand Down Expand Up @@ -88,6 +89,7 @@ export const TEXERA_FORMLY_CONFIG = {
{ name: "huggingface-image-upload", component: HuggingFaceImageUploadComponent, wrappers: ["form-field"] },
{ name: "repeat-section-dnd", component: FormlyRepeatDndComponent },
{ name: "ui-udf-parameters", component: UiUdfParametersComponent, wrappers: ["form-field"] },
{ name: "constrainedvalue", component: ConstrainedValueComponent, wrappers: ["form-field"] },
],
wrappers: [
{ name: "preset-wrapper", component: PresetWrapperComponent },
Expand Down
323 changes: 322 additions & 1 deletion frontend/src/app/common/formly/formly-utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,26 @@
* under the License.
*/

import { FormlyFieldConfig } from "@ngx-formly/core";
import { FormlyFieldConfig, FormlyModule } from "@ngx-formly/core";
import {
createOutputFormChangeEventStream,
createShouldHideFieldFunc,
createValueRulesValidator,
getFieldByName,
matchingValueRule,
setChildTypeDependency,
setHideExpression,
setValueRules,
valueRulesValidationMessage,
} from "./formly-utils";
import { ValueRuleSet } from "../../workspace/types/custom-json-schema.interface";
import { Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { AbstractControl, FormGroup, ReactiveFormsModule } from "@angular/forms";
import { FormlyNgZorroAntdModule } from "@ngx-formly/ng-zorro-antd";
import { TEXERA_FORMLY_CONFIG } from "./formly-config";
import { Subject } from "rxjs";
import { FORM_DEBOUNCE_TIME_MS } from "../../workspace/service/execute-workflow/execute-workflow.service";
import { PortSchema } from "../../workspace/types/workflow-compiling.interface";
Expand Down Expand Up @@ -206,3 +218,312 @@ describe("createOutputFormChangeEventStream", () => {
expect(modelCheck).toHaveBeenCalledTimes(2);
});
});

describe("valueRules", () => {
// the shape the sklearn trainers emit: one branch per hyperparameter, keyed on the
// `parameter` chosen beside the value in the same row
const rules: ValueRuleSet = {
allOf: [
{
if: { parameter: { valEnum: ["C"] } },
then: { type: "number", exclusiveMinimum: 0, examples: ["1.0"] },
},
{
if: { parameter: { valEnum: ["degree"] } },
then: { type: "integer", minimum: 0, examples: ["3"] },
},
{ if: { parameter: { valEnum: ["coef0"] } }, then: { type: "number", examples: ["0.0"] } },
// gamma takes either of two words or a number, which no type names
{
if: { parameter: { valEnum: ["gamma"] } },
then: { pattern: "^\\s*(?:scale|auto|[-+]?[0-9]*\\.?[0-9]+)\\s*$", examples: ["scale"] },
},
// an accepted set carries no example: it already names every value worth offering, and
// the estimator's own default leads
{
if: { parameter: { valEnum: ["kernel"] } },
then: { enum: ["rbf", "linear", "poly", "sigmoid", "precomputed"] },
},
],
};

const rowField = (row: unknown): FormlyFieldConfig => ({ parent: { model: row } }) as FormlyFieldConfig;
const control = (value: unknown) => ({ value }) as any;
const check = (parameter: string, value: unknown) =>
createValueRulesValidator(rules)(control(value), rowField({ parameter }));

describe("matchingValueRule", () => {
it("selects the branch the sibling's value names", () => {
expect(matchingValueRule(rules, { parameter: "kernel" })?.enum).toEqual([
"rbf",
"linear",
"poly",
"sigmoid",
"precomputed",
]);
expect(matchingValueRule(rules, { parameter: "degree" })?.type).toBe("integer");
});

it("selects nothing when the sibling holds a value no branch names", () => {
expect(matchingValueRule(rules, { parameter: "metric_params" })).toBeUndefined();
});

it("selects nothing before the row has a sibling value at all", () => {
expect(matchingValueRule(rules, {})).toBeUndefined();
expect(matchingValueRule(rules, undefined)).toBeUndefined();
expect(matchingValueRule(undefined, { parameter: "C" })).toBeUndefined();
});
});

describe("createValueRulesValidator", () => {
it("accepts a value the chosen parameter's set contains", () => {
expect(check("kernel", "rbf")).toBe(true);
});

it("rejects a value outside that set, including one of another parameter's", () => {
expect(check("kernel", "1")).toBe(false);
expect(check("kernel", "uniform")).toBe(false);
});

it("holds a numeric parameter to a number", () => {
expect(check("C", "1.0")).toBe(true);
// coef0 carries no bound, so it is where number-ness alone can be checked
expect(check("coef0", "-2.5e3")).toBe(true);
expect(check("C", "abc")).toBe(false);
});

it("holds a whole-number parameter to a whole number", () => {
expect(check("degree", "3")).toBe(true);
// int() raises on this, so the form should not let it reach the operator
expect(check("degree", "1.5")).toBe(false);
expect(check("coef0", "-1")).toBe(true);
});

it("leaves emptiness to the required rule rather than answering twice", () => {
expect(check("C", "")).toBe(true);
expect(check("C", null)).toBe(true);
expect(check("kernel", undefined)).toBe(true);
});

it("accepts anything for a parameter no branch constrains", () => {
expect(check("metric_params", "whatever")).toBe(true);
});

it("holds a value to the bound the estimator puts on it", () => {
// C is open at zero, degree is closed at it, and coef0 has no bound at all
expect(check("C", "0")).toBe(false);
expect(check("C", "-1")).toBe(false);
expect(check("C", "0.0001")).toBe(true);
expect(check("degree", "0")).toBe(true);
expect(check("degree", "-1")).toBe(false);
expect(check("coef0", "-100")).toBe(true);
});

it("holds a parameter with a pattern to the shape it declares", () => {
// both halves of the union it describes
expect(check("gamma", "scale")).toBe(true);
expect(check("gamma", "auto")).toBe(true);
expect(check("gamma", "0.1")).toBe(true);
expect(check("gamma", " 1 ")).toBe(true);
expect(check("gamma", "abc")).toBe(false);
expect(check("gamma", "scaleauto")).toBe(false);
});

it("judges the same value against whichever parameter the row now holds", () => {
// a value typed for one parameter is usually wrong for the next, and stays visible. That
// the form asks again when the parameter changes is the rendered form's test below
expect(check("C", "1.0")).toBe(true);
expect(check("kernel", "1.0")).toBe(false);
});
});

describe("valueRulesValidationMessage", () => {
const field = (parameter: string): FormlyFieldConfig =>
({ props: { valueRules: rules }, parent: { model: { parameter } } }) as FormlyFieldConfig;

it("names the accepted values when there is a set", () => {
expect(valueRulesValidationMessage(null, field("kernel"))).toBe(
"must be one of rbf, linear, poly, sigmoid, precomputed"
);
});

it("distinguishes a whole number from a number, and names the bound where there is one", () => {
expect(valueRulesValidationMessage(null, field("degree"))).toBe("must be a whole number of at least 0");
expect(valueRulesValidationMessage(null, field("C"))).toBe("must be a number greater than 0");
expect(valueRulesValidationMessage(null, field("coef0"))).toBe("must be a number");
});

it("points at a working value where a pattern is what the branch declares", () => {
expect(valueRulesValidationMessage(null, field("gamma"))).toBe(
"is not a value this parameter takes, such as scale"
);
});

it("says only what it knows when a pattern branch offers no example", () => {
const noExample: ValueRuleSet = {
allOf: [{ if: { parameter: { valEnum: ["gamma"] } }, then: { pattern: "^scale$" } }],
};
const bare = { props: { valueRules: noExample }, parent: { model: { parameter: "gamma" } } };
expect(valueRulesValidationMessage(null, bare as FormlyFieldConfig)).toBe("is not a value this parameter takes");
});

it("falls back to the numeric wording when no branch applies at all", () => {
// reached when the row's parameter changes between the check failing and the message
// being read, so the message must still say something rather than throw
expect(valueRulesValidationMessage(null, field("metric_params"))).toBe("must be a number");
});
});

/**
* Through a rendered form rather than a hand-made field, because what the field carries is
* only half of it: the other half is when Angular runs a validator, which is when the control
* carrying it changes and not when the parameter beside it does.
*/
describe("setValueRules in a rendered form", () => {
/** One `paraList` row: the parameter dropdown and the value field that follows it. */
@Component({
standalone: true,
imports: [ReactiveFormsModule, FormlyModule],
template: `<form [formGroup]="form">
<formly-form
[form]="form"
[fields]="fields"
[model]="model"></formly-form>
</form>`,
})
class RowHost {
readonly form = new FormGroup({});
readonly model: Record<string, unknown> = {
paraList: [
{ parameter: "C", value: "1.0" },
{ parameter: "kernel", value: "rbf" },
],
};
readonly fields: FormlyFieldConfig[] = [
{
key: "paraList",
type: "array",
fieldArray: {
fieldGroup: [
{
key: "parameter",
type: "enum",
props: {
options: ["C", "degree", "gamma", "kernel", "metric_params"].map(p => ({ label: p, value: p })),
},
},
valueField,
],
},
},
];
}

let valueField: FormlyFieldConfig;
let fixture: ComponentFixture<RowHost>;
let parameter: AbstractControl;
let value: AbstractControl;

beforeEach(async () => {
valueField = { key: "value" };
setValueRules(valueField, rules);

await TestBed.configureTestingModule({
imports: [RowHost, NoopAnimationsModule, FormlyModule.forRoot(TEXERA_FORMLY_CONFIG), FormlyNgZorroAntdModule],
}).compileComponents();

fixture = TestBed.createComponent(RowHost);
fixture.detectChanges();
parameter = rowControl(0, "parameter");
value = rowControl(0, "value");
});

const rowControl = (row: number, key: string): AbstractControl =>
fixture.componentInstance.form.get(`paraList.${row}.${key}`)!;

/** What the user does: picks a parameter, leaving whatever value the row already held. */
const choose = (parameterName: string) => {
parameter.setValue(parameterName);
fixture.detectChanges();
};

it("gives the value field the control and the validator the rules call for", () => {
expect(valueField.type).toBe("constrainedvalue");
expect(value.valid).toBe(true);
expect(fixture.debugElement.query(By.css("texera-constrained-value"))).not.toBeNull();
});

it("re-judges a value the row already holds when the parameter changes under it", () => {
// 1.0 is a C, and no kernel at all
expect(value.valid).toBe(true);

choose("kernel");

expect(value.valid).toBe(false);
expect(value.hasError("valueRules")).toBe(true);
});

it("clears the error once the parameter changes to one the value suits", () => {
choose("kernel");
expect(value.valid).toBe(false);

choose("gamma");

expect(value.valid).toBe(true);
});

it("re-judges when the new parameter constrains the value no rule did before", () => {
choose("metric_params");
value.setValue("1.5");
expect(value.valid).toBe(true);

choose("degree");

expect(value.valid).toBe(false);
});

it("leaves an empty value to the required rule whichever parameter it sits under", () => {
value.setValue("");
choose("kernel");
expect(value.valid).toBe(true);
});

it("gives the message of the parameter now chosen, not the one judged against", () => {
choose("kernel");
value.markAsTouched();
fixture.detectChanges();

expect(fixture.nativeElement.textContent).toContain("must be one of rbf, linear, poly, sigmoid, precomputed");
});

it("follows the parameter with the control the branch calls for", () => {
// scoped to the row's value field, so that neither the parameter's own dropdown nor the
// second row is what is seen
const valueControl = (selector: string) =>
fixture.debugElement.queryAll(By.css("texera-constrained-value"))[0].query(By.css(selector));
expect(valueControl("input[nz-input]").nativeElement.type).toBe("number");
expect(valueControl("nz-select")).toBeNull();

choose("kernel");

expect(valueControl("nz-select")).not.toBeNull();
expect(valueControl("input[nz-input]")).toBeNull();
});

it("re-judges only the row whose parameter changed", () => {
const otherValue = rowControl(1, "value");
expect(otherValue.valid).toBe(true);

choose("kernel");

// rbf is still a kernel, whatever the row above holds
expect(value.valid).toBe(false);
expect(otherValue.valid).toBe(true);

rowControl(1, "parameter").setValue("degree");
fixture.detectChanges();

expect(otherValue.valid).toBe(false);
});
});
});
Loading
Loading